nomadweb/redmini/tool/app.js
eric 2bcbc303d8 Fix redmini lifestyle/voices/FAQ to use real H5 DOM classes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 13:20:01 -05:00

982 lines
47 KiB
JavaScript

/**
* nomadro XHS offline mirror
* Reuses the same CSS classes / visual structure as frontend H5 (localhost:3000).
* No network — data from window.NOMADRO_DATA.
*/
(function () {
"use strict";
var D = window.NOMADRO_DATA || {};
var Bridge = window.XhsBridge;
var FAV_KEY = "nomadro_xhs_favs";
var root = document.getElementById("app-root");
var backBtn = document.getElementById("xhs-back");
var route = { name: "home", params: {} };
var favs = [];
var destFilter = "all";
var destSort = "rating";
var destSearch = "";
var visaFilter = "all";
var lifeActive = 0;
var voiceActive = 0;
var faqActive = null;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function on(el, ev, fn) {
if (el) el.addEventListener(ev, fn);
}
function storageGet(key, fb) {
if (Bridge && Bridge.getLocalData) {
return Bridge.getLocalData(key).then(function (v) { return v == null ? fb : v; });
}
try {
var raw = localStorage.getItem(key);
return Promise.resolve(raw ? JSON.parse(raw) : fb);
} catch (e) {
return Promise.resolve(fb);
}
}
function storageSet(key, val) {
if (Bridge && Bridge.setLocalData) return Bridge.setLocalData(key, val);
try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {}
return Promise.resolve(true);
}
function go(name, params) {
route = { name: name, params: params || {} };
render();
window.scrollTo(0, 0);
}
function destBySlug(slug) {
var list = D.destinations || [];
for (var i = 0; i < list.length; i++) if (list[i].slug === slug) return list[i];
return null;
}
function isFav(slug) { return favs.indexOf(slug) >= 0; }
function toggleFav(slug) {
var i = favs.indexOf(slug);
if (i >= 0) favs.splice(i, 1); else favs.push(slug);
storageSet(FAV_KEY, favs);
render();
}
function logoSvg(id) {
return (
'<svg class="logo-icon" viewBox="0 0 40 40" fill="none" aria-hidden="true">' +
'<circle cx="20" cy="20" r="18" stroke="url(#' + id + ')" stroke-width="2.5" />' +
'<path d="M12 22c4-8 12-8 16 0" stroke="url(#' + id + ')" stroke-width="2" stroke-linecap="round" />' +
'<circle cx="28" cy="14" r="3" fill="url(#' + id + ')" />' +
'<defs><linearGradient id="' + id + '" x1="0" y1="0" x2="40" y2="40">' +
'<stop offset="0%" stop-color="#FF6B6B" /><stop offset="50%" stop-color="#FFE66D" /><stop offset="100%" stop-color="#4ECDC4" />' +
"</linearGradient></defs></svg>"
);
}
/* ===== chrome shared with H5 ===== */
function renderNavbar() {
return (
'<nav class="navbar scrolled" id="navbar">' +
'<div class="nav-inner">' +
'<a href="#/" class="logo" data-nav="home">' + logoSvg("navLogoGrad") + "<span>nomadro</span></a>" +
'<div class="nav-links open-static">' +
'<a href="#/explore" data-nav="explore">探索</a>' +
'<a href="#/connect" data-nav="connect">连接</a>' +
'<a href="#/grow" data-nav="grow">成长</a>' +
'<a href="#/mine" data-nav="mine">我的</a>' +
"</div>" +
'<div class="nav-actions">' +
'<span class="nav-chip">离线</span>' +
"</div>" +
"</div>" +
"</nav>"
);
}
function renderTicker() {
var msgs = D.ticker || [];
if (!msgs.length) return "";
return (
'<div class="ticker-bar"><div class="ticker-track">' +
msgs.concat(msgs).map(function (m) {
return '<span class="ticker-item">' + esc(m) + "</span>";
}).join("") +
"</div></div>"
);
}
function renderHero() {
var stats = D.stats || {};
return (
'<section class="hero" id="hero">' +
'<div class="hero-bg-shapes"><div class="shape shape-1"></div><div class="shape shape-2"></div><div class="shape shape-3"></div></div>' +
'<div class="hero-inner">' +
'<div class="hero-top">' +
'<div class="hero-content">' +
'<div class="hero-badge reveal visible"><span class="badge-dot"></span>nomadro · ' +
esc(stats.total_nomads || "35.6M") + " 全球数字游民正在路上</div>" +
'<h1 class="hero-title reveal visible">用一行代码<br /><span class="gradient-text">环游世界</span> 🌏</h1>' +
'<p class="hero-subtitle reveal visible"><span id="typewriter"></span><span class="typewriter-cursor">|</span></p>' +
"</div>" +
'<div class="hero-visual reveal visible">' +
'<div class="globe-container">' +
'<svg class="globe-svg" viewBox="0 0 400 400">' +
"<defs>" +
'<radialGradient id="globeGlow" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#4ECDC4" stop-opacity="0.3" /><stop offset="100%" stop-color="#4ECDC4" stop-opacity="0" /></radialGradient>' +
'<linearGradient id="orbitGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#FF6B6B" /><stop offset="100%" stop-color="#4ECDC4" /></linearGradient>' +
"</defs>" +
'<circle cx="200" cy="200" r="160" fill="url(#globeGlow)" />' +
'<circle cx="200" cy="200" r="120" fill="none" stroke="rgba(78,205,196,0.2)" stroke-width="1" />' +
'<ellipse cx="200" cy="200" rx="120" ry="40" fill="none" stroke="rgba(78,205,196,0.3)" stroke-width="1.5" class="orbit-ring" />' +
'<ellipse cx="200" cy="200" rx="40" ry="120" fill="none" stroke="rgba(255,107,107,0.3)" stroke-width="1.5" class="orbit-ring orbit-ring-2" />' +
'<path d="M160 140 Q180 130 200 135 Q220 128 240 140 Q250 155 245 170 Q235 185 220 190 Q200 195 185 188 Q165 180 160 165 Z" fill="rgba(78,205,196,0.4)" class="continent" />' +
'<g class="nomad-dots">' +
'<circle cx="180" cy="155" r="5" fill="#FF6B6B" class="pulse-dot" />' +
'<circle cx="260" cy="215" r="5" fill="#FFE66D" class="pulse-dot" />' +
'<circle cx="145" cy="225" r="5" fill="#4ECDC4" class="pulse-dot" />' +
'<circle cx="220" cy="175" r="5" fill="#A78BFA" class="pulse-dot" />' +
"</g>" +
'<path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" stroke-width="2" stroke-dasharray="6 4" class="flight-path" />' +
"</svg>" +
'<div class="floating-cards">' +
'<div class="float-card fc-1">🏝️ Bali<br /><small>WiFi 98Mbps</small></div>' +
'<div class="float-card fc-2">☕ Lisbon<br /><small>€800/mo</small></div>' +
'<div class="float-card fc-3">🏔️ Chiang Mai<br /><small>⭐ 4.9</small></div>' +
"</div>" +
"</div>" +
"</div>" +
"</div>" +
'<div class="hero-bottom reveal visible">' +
'<div class="hero-actions">' +
'<button type="button" class="btn btn-primary" data-nav="matcher"><span>🎯 智能匹配去哪</span></button>' +
'<a href="#destinations" class="btn btn-ghost" data-scroll="destinations"><span>从城市开始</span></a>' +
"</div>" +
"</div>" +
"</div>" +
'<div class="scroll-indicator"><span>向下滚动</span></div>' +
"</section>"
);
}
function renderJourney() {
return (
'<section class="journey-strip section-compact" id="path">' +
'<div class="container">' +
'<div class="section-header journey-head reveal visible">' +
'<span class="section-tag">PATH</span><h2>三条环,走通旅居</h2>' +
"<p>探索去哪 → 遇见同行 → 路上成长</p>" +
"</div>" +
'<ol class="journey-rail journey-rail-3 reveal visible">' +
'<li class="journey-step"><div class="journey-card">' +
'<div class="journey-card-top"><span class="journey-n">01</span><span class="journey-emoji">🌍</span></div>' +
"<h3>探索</h3><p>目的地、签证与智能匹配</p>" +
'<div class="journey-actions"><button type="button" class="btn btn-sm btn-primary" data-nav="explore">开始探索</button></div>' +
"</div></li>" +
'<li class="journey-step"><div class="journey-card">' +
'<div class="journey-card-top"><span class="journey-n">02</span><span class="journey-emoji">🤝</span></div>' +
"<h3>连接</h3><p>活动、社区与游民心声</p>" +
'<div class="journey-actions"><button type="button" class="btn btn-sm btn-primary" data-nav="connect">去连接</button></div>' +
"</div></li>" +
'<li class="journey-step"><div class="journey-card">' +
'<div class="journey-card-top"><span class="journey-n">03</span><span class="journey-emoji">🚀</span></div>' +
"<h3>成长</h3><p>学院、博客与工具箱</p>" +
'<div class="journey-actions"><button type="button" class="btn btn-sm btn-primary" data-nav="grow">去成长</button></div>' +
"</div></li>" +
"</ol>" +
"</div>" +
"</section>"
);
}
function filteredDests() {
var list = (D.destinations || []).slice();
if (destFilter !== "all") list = list.filter(function (d) { return d.region === destFilter; });
if (destSearch) {
var q = destSearch.toLowerCase();
list = list.filter(function (d) {
return String(d.name).toLowerCase().indexOf(q) >= 0 || String(d.country).toLowerCase().indexOf(q) >= 0;
});
}
if (destSort === "cost-asc") list.sort(function (a, b) { return a.cost - b.cost; });
else if (destSort === "cost-desc") list.sort(function (a, b) { return b.cost - a.cost; });
else if (destSort === "speed") list.sort(function (a, b) { return b.speed - a.speed; });
else list.sort(function (a, b) { return b.rating - a.rating; });
return list;
}
function destCard(d) {
var hue = d.hue || 170;
return (
'<article class="dest-card reveal visible">' +
'<a href="#/destination/' + esc(d.slug) + '" class="dest-card-link" data-dest="' + esc(d.slug) + '">' +
'<div class="dest-img" style="--hue:' + hue + '">' +
'<div class="dest-emoji">' + esc(d.emoji) + "</div>" +
'<div class="dest-overlay"><span class="dest-tag">' + esc(d.tag) + "</span></div>" +
"</div>" +
'<div class="dest-body">' +
"<h3>" + esc(d.name) + ", " + esc(d.country) +
(isFav(d.slug) ? " ★" : "") + "</h3>" +
"<p>" + esc(d.description) + "</p>" +
'<div class="dest-meta">' +
"<span>💰 ¥" + Number(d.cost).toLocaleString() + "</span>" +
"<span>📶 " + d.speed + "Mbps</span>" +
"<span>🌡️ " + d.temperature + "°C</span>" +
"</div>" +
'<div class="dest-rating"><span class="stars">' +
"⭐".repeat(Math.max(1, Math.round((d.rating || 8) / 2))) +
"</span><span>" + d.rating + "</span></div>" +
"</div>" +
"</a>" +
"</article>"
);
}
function renderDestinations(limit) {
var list = filteredDests();
var visible = typeof limit === "number" ? list.slice(0, limit) : list;
var filters = [
{ key: "all", label: "全部" },
{ key: "sea", label: "东南亚" },
{ key: "europe", label: "欧洲" },
{ key: "latam", label: "拉美" },
{ key: "asia", label: "东亚" },
];
return (
'<section class="section destinations section-compact" id="destinations">' +
'<div class="container">' +
'<div class="section-header reveal visible">' +
'<span class="section-tag">🌴 TOP DESTINATIONS</span>' +
"<h2>热门旅居目的地</h2>" +
"<p>按预算、网速与气候筛选,点进城市看详情</p>" +
'<button type="button" class="btn btn-primary dest-matcher-btn" data-nav="matcher">🎯 智能匹配</button>' +
"</div>" +
'<div class="dest-filters reveal visible">' +
'<div class="filter-search">' +
'<input id="dest-search" placeholder="搜索城市 / 国家" value="' + esc(destSearch) + '" />' +
"</div>" +
'<div class="filter-tags">' +
filters.map(function (f) {
return '<button type="button" class="filter-btn' + (destFilter === f.key ? " active" : "") +
'" data-dest-filter="' + f.key + '">' + f.label + "</button>";
}).join("") +
"</div>" +
'<div class="filter-sort"><select id="dest-sort">' +
'<option value="rating"' + (destSort === "rating" ? " selected" : "") + ">评分</option>" +
'<option value="cost-asc"' + (destSort === "cost-asc" ? " selected" : "") + ">费用低→高</option>" +
'<option value="cost-desc"' + (destSort === "cost-desc" ? " selected" : "") + ">费用高→低</option>" +
'<option value="speed"' + (destSort === "speed" ? " selected" : "") + ">网速</option>" +
"</select></div>" +
"</div>" +
'<div class="dest-grid" id="dest-grid">' + visible.map(destCard).join("") + "</div>" +
(typeof limit === "number" && list.length > limit
? '<div style="text-align:center;margin-top:20px"><button type="button" class="btn btn-ghost" data-nav="explore">查看全部 ' + list.length + " 座城市</button></div>"
: "") +
"</div>" +
"</section>"
);
}
function renderVisas() {
var list = (D.visas || []).slice();
if (visaFilter === "easy") list = list.filter(function (v) { return v.difficulty <= 35; });
else if (visaFilter === "medium") list = list.filter(function (v) { return v.difficulty > 35 && v.difficulty <= 55; });
else if (visaFilter === "budget") list = list.filter(function (v) { return v.badge_type === "budget"; });
var filters = [
{ key: "all", label: "🌏 全部" },
{ key: "easy", label: "✅ 简单" },
{ key: "medium", label: "📋 中等" },
{ key: "budget", label: "💰 低成本" },
];
return (
'<section class="section visa-section section-compact" id="visa">' +
'<div class="container">' +
'<div class="section-header reveal visible">' +
'<span class="section-tag">📋 VISA</span><h2>远程友好签证</h2>' +
"<p>离线快照,出发前请再核对官方政策</p>" +
"</div>" +
'<div class="visa-filters reveal visible">' +
filters.map(function (f) {
return '<button type="button" class="filter-btn' + (visaFilter === f.key ? " active" : "") +
'" data-visa-filter="' + f.key + '">' + f.label + "</button>";
}).join("") +
"</div>" +
'<div class="visa-grid">' +
list.map(function (v, i) {
return (
'<div class="visa-card reveal visible" style="animation-delay:' + (i * 0.05) + 's">' +
'<div class="visa-flag">' + esc(v.flag) + "</div>" +
"<h3>" + esc(v.name) + "</h3>" +
'<div class="visa-badge ' + esc(v.badge_type) + '">' + esc(v.badge) + "</div>" +
'<ul class="visa-details">' +
"<li>📅 " + esc(v.duration) + "</li>" +
"<li>💰 " + esc(v.income_req) + "</li>" +
"<li>⏱️ " + esc(v.approval_time) + "</li>" +
"<li>" + esc(v.extra) + "</li>" +
"</ul>" +
'<div class="visa-progress"><span>难度</span>' +
'<div class="progress-bar"><div class="progress-fill" style="width:' + v.difficulty + '%"></div></div>' +
"<span>" + esc(v.difficulty_label) + "</span>" +
"</div>" +
"</div>"
);
}).join("") +
"</div>" +
"</div>" +
"</section>"
);
}
function renderVoices() {
var C = D.homeCommunity || {};
var list = D.testimonials || [];
var cur = list[voiceActive] || list[0];
if (!cur) return "";
return (
'<section class="section community" id="voices">' +
'<div class="container">' +
'<div class="section-header reveal visible">' +
'<span class="section-tag">' + esc(C.tag || "🤝 COMMUNITY") + "</span>" +
"<h2>" + esc(C.title || "游民心声") + "</h2>" +
"<p>" + esc(C.subtitle || "来自全球数字游民的真实故事") + "</p>" +
"</div>" +
'<div class="testimonial-carousel reveal visible">' +
'<div class="testimonial-featured">' +
'<div class="testimonial-avatar">' + esc(cur.avatar) + "</div>" +
'<div class="testimonial-content">' +
'<div class="testimonial-stars">' + "⭐".repeat(cur.rating || 5) + "</div>" +
"<p>“" + esc(cur.content) + "”</p>" +
'<div class="testimonial-author"><strong>' + esc(cur.author) + "</strong><span>" + esc(cur.role) + "</span></div>" +
"</div>" +
"</div>" +
'<div class="testimonial-dots">' +
list.map(function (_t, i) {
return '<button type="button" class="testimonial-dot' + (i === voiceActive ? " active" : "") +
'" data-voice="' + i + '" aria-label="' + (i + 1) + '"></button>';
}).join("") +
"</div>" +
"</div>" +
'<div class="testimonials testimonials-grid">' +
list.map(function (item) {
return (
'<div class="testimonial-card reveal visible">' +
'<div class="testimonial-avatar">' + esc(item.avatar) + "</div>" +
'<div class="testimonial-content">' +
'<div class="testimonial-stars">' + "⭐".repeat(item.rating || 5) + "</div>" +
"<p>“" + esc(item.content) + "”</p>" +
'<div class="testimonial-author"><strong>' + esc(item.author) + "</strong><span>" + esc(item.role) + "</span></div>" +
"</div>" +
"</div>"
);
}).join("") +
"</div>" +
"</div>" +
"</section>"
);
}
function renderLifestyle() {
var L = D.lifestyle || {};
var items = L.items || [];
if (!items.length) return "";
if (lifeActive < 0 || lifeActive >= items.length) lifeActive = 0;
var cur = items[lifeActive];
return (
'<section class="section lifestyle" id="lifestyle">' +
'<div class="container">' +
'<div class="section-header reveal visible">' +
'<span class="section-tag">' + esc(L.tag || "💻 NOMAD LIFE") + "</span>" +
"<h2>" + esc(L.title || "数字游民的一天") + "</h2>" +
"<p>" + esc(L.subtitle || "") + "</p>" +
"</div>" +
'<div class="lifestyle-layout reveal visible">' +
'<div class="timeline">' +
'<div class="timeline-line">' +
'<svg viewBox="0 0 4 600" preserveAspectRatio="none">' +
'<line x1="2" y1="0" x2="2" y2="600" stroke="url(#timelineGrad)" stroke-width="3" stroke-dasharray="8 6" class="timeline-svg-line" />' +
"<defs><linearGradient id=\"timelineGrad\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">" +
'<stop offset="0%" stop-color="#FF6B6B" /><stop offset="50%" stop-color="#FFE66D" /><stop offset="100%" stop-color="#4ECDC4" />' +
"</linearGradient></defs>" +
"</svg>" +
"</div>" +
items.map(function (item, i) {
return (
'<div class="timeline-item' + (lifeActive === i ? " active" : "") + '" data-life="' + i + '" role="button" tabindex="0">' +
'<div class="timeline-time">' + esc(item.time) + "</div>" +
'<div class="timeline-card">' +
'<div class="timeline-icon">' + esc(item.icon) + "</div>" +
"<h3>" + esc(item.title) + "</h3>" +
"<p>" + esc(item.desc) + "</p>" +
"</div>" +
"</div>"
);
}).join("") +
"</div>" +
'<div class="lifestyle-detail">' +
'<div class="lifestyle-detail-card">' +
'<span class="lifestyle-detail-icon">' + esc(cur.icon) + "</span>" +
'<span class="lifestyle-detail-time">' + esc(cur.time) + "</span>" +
"<h3>" + esc(cur.title) + "</h3>" +
"<p>" + esc(cur.desc) + "</p>" +
'<div class="lifestyle-tip"><span>💡</span><p>' + esc(cur.tip) + "</p></div>" +
'<div class="dating-empty-actions" style="margin-top:16px">' +
'<button type="button" class="btn btn-primary btn-sm" data-nav="digital">' + esc(L.ctaDay || "七天落地指南") + "</button>" +
'<button type="button" class="btn btn-sm" data-nav="meetups">' + esc(L.ctaMeetups || "去参加活动") + "</button>" +
"</div>" +
'<div class="lifestyle-dots">' +
items.map(function (_item, i) {
return '<button type="button" class="lifestyle-dot' + (lifeActive === i ? " active" : "") +
'" data-life="' + i + '" aria-label="' + (i + 1) + '"></button>';
}).join("") +
"</div>" +
"</div>" +
"</div>" +
"</div>" +
"</div>" +
"</section>"
);
}
function renderFaq() {
return (
'<section class="section faq-section section-compact" id="faq">' +
'<div class="container">' +
'<div class="section-header reveal visible">' +
'<span class="section-tag">❓ FAQ</span><h2>常见问题</h2><p>启动资金、网络、保险与税务</p>' +
"</div>" +
'<div class="faq-list">' +
(D.faqs || []).map(function (f) {
var open = faqActive === f.id;
return (
'<div class="faq-item reveal visible' + (open ? " active" : "") + '">' +
'<button type="button" class="faq-question" data-faq="' + esc(f.id) + '">' +
"<span>" + esc(f.question) + "</span>" +
'<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>' +
"</button>" +
'<div class="faq-answer"><p>' + esc(f.answer) + "</p></div>" +
"</div>"
);
}).join("") +
"</div>" +
"</div>" +
"</section>"
);
}
function renderFooter() {
return (
'<footer class="footer">' +
'<div class="container">' +
'<div class="footer-grid footer-grid-rings">' +
'<div class="footer-brand">' +
'<a href="#/" class="logo" data-nav="home">' + logoSvg("footerLogoGrad") + "<span>nomadro</span></a>" +
"<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>" +
"<p style=\"margin-top:8px;color:var(--text-muted);font-size:12px\">小红书离线镜像 · 完整站 nomadweb.nomadro.com</p>" +
"</div>" +
'<div class="footer-links"><h4>探索</h4>' +
'<a href="#/explore" data-nav="explore">目的地</a>' +
'<a href="#/visas" data-nav="visas">签证</a>' +
'<a href="#/matcher" data-nav="matcher">智能匹配</a></div>' +
'<div class="footer-links"><h4>连接</h4>' +
'<a href="#/meetups" data-nav="meetups">活动</a>' +
'<a href="#/community" data-nav="community">社区</a></div>' +
'<div class="footer-links"><h4>成长</h4>' +
'<a href="#/digital" data-nav="digital">学院</a>' +
'<a href="#/blog" data-nav="blog">博客</a>' +
'<a href="#/tools" data-nav="tools">工具箱</a></div>' +
"</div>" +
"</div>" +
"</footer>"
);
}
function pageHome() {
return (
'<div class="home-flow">' +
renderNavbar() +
renderTicker() +
renderHero() +
renderJourney() +
renderDestinations(6) +
renderLifestyle() +
renderVisas() +
renderVoices() +
renderFaq() +
renderFooter() +
"</div>"
);
}
function pageExplore() {
return (
'<div class="home-flow">' +
renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 24px)">' +
'<div class="section-header reveal visible"><span class="section-tag">🧭 EXPLORE</span><h2>探索</h2><p>发现去哪</p></div>' +
'<div class="tools-grid" style="margin-bottom:24px">' +
'<button type="button" class="tool-card" data-nav="matcher"><span class="tool-emoji">🎯</span><h3>智能匹配</h3><p>按预算气候排序</p></button>' +
'<button type="button" class="tool-card" data-nav="visas"><span class="tool-emoji">📋</span><h3>签证指南</h3><p>远程友好签证</p></button>' +
'<button type="button" class="tool-card" data-nav="compare"><span class="tool-emoji">⚖️</span><h3>城市对比</h3><p>并排看成本网速</p></button>' +
"</div>" +
"</div>" +
renderDestinations() +
renderFooter() +
"</div>"
);
}
function pageDestination(slug) {
var d = destBySlug(slug);
if (!d) return '<div class="container" style="padding:120px 24px"><p>未找到城市</p></div>';
return (
'<div class="home-flow">' +
renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:48px">' +
'<nav class="detail-nav"><a href="#/explore" data-nav="explore">← 目的地</a></nav>' +
'<div class="dest-detail-hero" style="--hue:' + (d.hue || 170) + '">' +
'<div class="dest-emoji" style="font-size:64px">' + esc(d.emoji) + "</div>" +
"<h1>" + esc(d.name) + " · " + esc(d.country) + "</h1>" +
'<p class="section-desc">' + esc(d.tag) + " · " + esc(d.nomads_count) + " 游民</p>" +
"<p style=\"margin-top:12px;max-width:640px\">" + esc(d.description) + "</p>" +
'<div class="hero-actions" style="margin-top:16px">' +
'<button type="button" class="btn btn-primary" data-fav="' + esc(d.slug) + '">' +
(isFav(d.slug) ? "取消收藏" : "收藏") + "</button>" +
'<button type="button" class="btn btn-ghost" data-nav="meetups">同城活动</button>' +
"</div>" +
"</div>" +
'<div class="dest-meta" style="margin:20px 0;display:flex;flex-wrap:wrap">' +
"<span>💰 ¥" + Number(d.cost).toLocaleString() + "/月</span>" +
"<span style=\"margin-left:16px\">📶 " + d.speed + "Mbps</span>" +
"<span style=\"margin-left:16px\">🌡️ " + d.temperature + "°C</span>" +
"<span style=\"margin-left:16px\">⭐ " + d.rating + "</span>" +
"</div>" +
'<div class="hl" style="display:flex;flex-wrap:wrap">' +
(d.highlights || []).map(function (h) {
return '<span class="dest-tag" style="margin:4px 8px 4px 0">' + esc(h) + "</span>";
}).join("") +
"</div>" +
"</div>" +
renderFooter() +
"</div>"
);
}
function pageConnect() {
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px)">' +
'<div class="section-header reveal visible"><span class="section-tag">🤝 CONNECT</span><h2>连接</h2><p>遇见同行(离线可浏览)</p></div>' +
'<div class="tools-grid">' +
'<button type="button" class="tool-card" data-nav="meetups"><span class="tool-emoji">🎉</span><h3>游民活动</h3><p>同城与线上</p></button>' +
'<button type="button" class="tool-card" data-nav="community"><span class="tool-emoji">💬</span><h3>社区讨论</h3><p>签证住宿经验</p></button>' +
'<button type="button" class="tool-card" data-nav="voices"><span class="tool-emoji">🗣️</span><h3>游民心声</h3><p>真实评价</p></button>' +
"</div></div>" + renderFooter() + "</div>"
);
}
function pageMeetups() {
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:40px">' +
'<div class="section-header reveal visible"><span class="section-tag">🎉 MEETUPS</span><h2>游民活动</h2><p>离线快照 · RSVP 请到网页版</p></div>' +
'<div class="dest-grid">' +
(D.meetups || []).map(function (m) {
return (
'<article class="dest-card reveal visible"><div class="dest-body">' +
'<div class="dest-emoji" style="font-size:32px;margin-bottom:8px">' + esc(m.emoji || "🎉") + "</div>" +
"<h3>" + esc(m.title) + "</h3>" +
"<p>" + esc(m.description) + "</p>" +
'<div class="dest-meta"><span>' + esc(m.city) + "</span><span>" + esc(m.date) + " " + esc(m.time || "") +
"</span><span>" + (m.rsvp_count || 0) + " 人</span></div>" +
"</div></article>"
);
}).join("") +
"</div></div>" + renderFooter() + "</div>"
);
}
function pageCommunity() {
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:40px">' +
'<div class="section-header reveal visible"><span class="section-tag">💬 COMMUNITY</span><h2>社区讨论</h2></div>' +
'<div class="faq-list">' +
(D.discussions || []).map(function (d) {
return (
'<div class="faq-item reveal visible" style="padding:16px;margin-bottom:12px;background:var(--bg-card);border:var(--border-glass);border-radius:var(--radius-md)">' +
"<h3 style=\"font-size:16px\">" + (d.is_pinned ? "📌 " : "") + esc(d.title) + "</h3>" +
"<p style=\"margin-top:8px;color:var(--text-secondary)\">" + esc(d.excerpt) + "</p>" +
'<div class="dest-meta" style="margin-top:10px"><span>' + esc(d.author) + "</span><span>" +
(d.reply_count || 0) + " 回复</span><span>♥ " + (d.like_count || 0) + "</span></div>" +
"</div>"
);
}).join("") +
"</div></div>" + renderFooter() + "</div>"
);
}
function pageGrow() {
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px)">' +
'<div class="section-header reveal visible"><span class="section-tag">🌱 GROW</span><h2>成长</h2><p>路上做事</p></div>' +
'<div class="tools-grid">' +
'<button type="button" class="tool-card" data-nav="digital"><span class="tool-emoji">🎓</span><h3>游民学院</h3><p>远程工作课程</p></button>' +
'<button type="button" class="tool-card" data-nav="blog"><span class="tool-emoji">📝</span><h3>博客</h3><p>指南与攻略</p></button>' +
'<button type="button" class="tool-card" data-nav="tools"><span class="tool-emoji">🧰</span><h3>工具箱</h3><p>费用估算</p></button>' +
'<button type="button" class="tool-card" data-nav="faq"><span class="tool-emoji">❓</span><h3>FAQ</h3><p>常见问题</p></button>' +
"</div></div>" + renderFooter() + "</div>"
);
}
function pageDigital() {
var modules = D.course || [];
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:40px">' +
'<div class="section-header reveal visible"><span class="section-tag">🎓 ACADEMY</span><h2>游民学院</h2></div>' +
modules.map(function (mod, mi) {
return (
'<div class="section-header" style="margin-top:24px"><h3>' + esc(mod.title) + "</h3></div>" +
'<div class="tools-grid">' +
(mod.lessons || []).map(function (les, li) {
var key = mi + "-" + li;
var lesson = (D.lessons || {})[key] || {};
return (
'<article class="tool-card">' +
"<h3>" + (les.free ? "🆓 " : "🔒 ") + esc(les.title) + "</h3>" +
"<p>" + esc(les.duration) + "</p>" +
"<p style=\"margin-top:8px;color:var(--text-secondary);font-size:13px\">" + esc(lesson.content || "") + "</p>" +
"</article>"
);
}).join("") +
"</div>"
);
}).join("") +
"</div>" + renderFooter() + "</div>"
);
}
function pageBlog() {
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:40px">' +
'<div class="section-header reveal visible"><span class="section-tag">📝 BLOG</span><h2>博客</h2></div>' +
'<div class="dest-grid">' +
(D.blog || []).map(function (b) {
return (
'<article class="dest-card reveal visible">' +
'<a href="#/blog/' + esc(b.slug) + '" class="dest-card-link" data-blog="' + esc(b.slug) + '">' +
'<div class="dest-body">' +
'<div class="dest-emoji" style="font-size:36px">' + esc(b.emoji) + "</div>" +
"<h3>" + esc(b.title) + "</h3>" +
"<p>" + esc(b.excerpt) + "</p>" +
'<div class="dest-meta"><span>' + esc(b.author) + "</span><span>" + esc(b.published_at) +
"</span><span>" + (b.read_time || "?") + " 分钟</span></div>" +
"</div></a></article>"
);
}).join("") +
"</div></div>" + renderFooter() + "</div>"
);
}
function pageBlogDetail(slug) {
var b = null;
var list = D.blog || [];
for (var i = 0; i < list.length; i++) if (list[i].slug === slug) b = list[i];
if (!b) return '<div class="container" style="padding:120px 24px"><p>文章不存在</p></div>';
var content = (D.blogContent && D.blogContent[slug]) || b.excerpt || "";
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:48px;max-width:720px">' +
'<nav class="detail-nav"><a href="#/blog" data-nav="blog">← 博客</a></nav>' +
'<div class="section-header"><span class="section-tag">' + esc(b.emoji) + "</span>" +
"<h1 style=\"font-size:28px\">" + esc(b.title) + "</h1>" +
'<p>' + esc(b.author) + " · " + esc(b.published_at) + " · " + (b.read_time || "?") + " 分钟</p></div>" +
'<article class="blog-prose" style="white-space:pre-wrap;line-height:1.75;color:var(--text-secondary)">' +
esc(content) +
"</article>" +
"</div>" + renderFooter() + "</div>"
);
}
function pageTools() {
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:40px">' +
'<div class="section-header reveal visible"><span class="section-tag">🧰 TOOLS</span><h2>工具箱</h2></div>' +
'<div class="tools-grid">' +
(D.tools || []).map(function (t) {
return (
'<article class="tool-card"><span class="tool-emoji">' + esc(t.emoji) + "</span>" +
"<h3>" + esc(t.name) + "</h3><p>" + esc(t.description) + "</p></article>"
);
}).join("") +
'<article class="tool-card"><span class="tool-emoji">🧮</span><h3>月费用估算</h3>' +
'<div style="margin-top:12px">' +
'<label style="font-size:12px;color:var(--text-muted)">住宿</label>' +
'<input id="c-house" type="number" value="2500" style="width:100%;margin:4px 0 8px;padding:8px;border-radius:8px;border:var(--border-glass);background:var(--bg-glass);color:inherit" />' +
'<label style="font-size:12px;color:var(--text-muted)">餐饮</label>' +
'<input id="c-food" type="number" value="1500" style="width:100%;margin:4px 0 8px;padding:8px;border-radius:8px;border:var(--border-glass);background:var(--bg-glass);color:inherit" />' +
'<label style="font-size:12px;color:var(--text-muted)">其他</label>' +
'<input id="c-other" type="number" value="1500" style="width:100%;margin:4px 0 8px;padding:8px;border-radius:8px;border:var(--border-glass);background:var(--bg-glass);color:inherit" />' +
'<button type="button" class="btn btn-primary btn-sm" id="c-run">计算</button>' +
'<p id="c-out" style="margin-top:10px;color:var(--accent-3)"></p>' +
"</div></article>" +
"</div></div>" + renderFooter() + "</div>"
);
}
function pageMatcher() {
var budget = 6000;
var scored = (D.destinations || []).map(function (d) {
var score = 40 + (d.rating || 0) * 4 + Math.min(20, (d.speed || 0) / 10);
if (d.cost <= budget) score += 20; else score -= 10;
return { d: d, score: Math.round(score) };
}).sort(function (a, b) { return b.score - a.score; });
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:40px">' +
'<div class="section-header reveal visible"><span class="section-tag">🎯 MATCHER</span><h2>智能匹配</h2><p>离线本地打分 · 默认预算 ¥6000</p></div>' +
'<div class="dest-grid">' + scored.map(function (item) {
return destCard(item.d).replace("</h3>", " · 匹配 " + item.score + "</h3>");
}).join("") + "</div>" +
"</div>" + renderFooter() + "</div>"
);
}
function pageCompare() {
var list = D.destinations || [];
var a = list[0] || {};
var b = list[1] || {};
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:40px">' +
'<div class="section-header reveal visible"><span class="section-tag">⚖️ COMPARE</span><h2>城市对比</h2></div>' +
'<div class="dest-grid">' +
(a.slug ? destCard(a) : "") +
(b.slug ? destCard(b) : "") +
"</div>" +
'<div class="container" style="margin-top:20px;overflow:auto">' +
'<table style="width:100%;border-collapse:collapse;font-size:14px">' +
"<tr><th align=\"left\">指标</th><th align=\"left\">" + esc(a.name) + "</th><th align=\"left\">" + esc(b.name) + "</th></tr>" +
"<tr><td>月费</td><td>¥" + a.cost + "</td><td>¥" + b.cost + "</td></tr>" +
"<tr><td>网速</td><td>" + a.speed + "</td><td>" + b.speed + "</td></tr>" +
"<tr><td>均温</td><td>" + a.temperature + "</td><td>" + b.temperature + "</td></tr>" +
"<tr><td>评分</td><td>" + a.rating + "</td><td>" + b.rating + "</td></tr>" +
"</table></div>" +
"</div>" + renderFooter() + "</div>"
);
}
function pageMine() {
var cards = favs.map(function (slug) {
var d = destBySlug(slug);
return d ? destCard(d) : "";
}).join("");
return (
'<div class="home-flow">' + renderNavbar() +
'<div class="container" style="padding-top:calc(var(--nav-height) + 32px);padding-bottom:40px">' +
'<div class="section-header reveal visible"><span class="section-tag">👤 MINE</span><h2>我的</h2>' +
"<p>" + esc(D.offlineNote || "") + "</p></div>" +
'<h3 style="margin:16px 0">收藏的城市</h3>' +
'<div class="dest-grid">' + (cards || '<p class="dest-empty">还没有收藏</p>') + "</div>" +
"</div>" + renderFooter() + "</div>"
);
}
function pageVisasOnly() {
return '<div class="home-flow">' + renderNavbar() +
'<div style="padding-top:var(--nav-height)">' + renderVisas() + "</div>" + renderFooter() + "</div>";
}
function pageVoicesOnly() {
return '<div class="home-flow">' + renderNavbar() +
'<div style="padding-top:var(--nav-height)">' + renderVoices() + "</div>" + renderFooter() + "</div>";
}
function pageFaqOnly() {
return '<div class="home-flow">' + renderNavbar() +
'<div style="padding-top:var(--nav-height)">' + renderFaq() + "</div>" + renderFooter() + "</div>";
}
function render() {
var html = "";
switch (route.name) {
case "home": html = pageHome(); break;
case "explore": html = pageExplore(); break;
case "destination": html = pageDestination(route.params.slug); break;
case "connect": html = pageConnect(); break;
case "meetups": html = pageMeetups(); break;
case "community": html = pageCommunity(); break;
case "voices": html = pageVoicesOnly(); break;
case "grow": html = pageGrow(); break;
case "digital": html = pageDigital(); break;
case "blog": html = pageBlog(); break;
case "blog-detail": html = pageBlogDetail(route.params.slug); break;
case "tools": html = pageTools(); break;
case "matcher": html = pageMatcher(); break;
case "compare": html = pageCompare(); break;
case "visas": html = pageVisasOnly(); break;
case "faq": html = pageFaqOnly(); break;
case "mine": html = pageMine(); break;
default: html = pageHome();
}
root.innerHTML = html;
backBtn.className = "xhs-back" + (route.name === "home" ? "" : " is-on");
bind();
startTypewriter();
}
function bind() {
Array.prototype.forEach.call(document.querySelectorAll("[data-nav]"), function (el) {
on(el, "click", function (e) {
e.preventDefault();
go(el.getAttribute("data-nav"));
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-dest]"), function (el) {
on(el, "click", function (e) {
e.preventDefault();
go("destination", { slug: el.getAttribute("data-dest") });
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-blog]"), function (el) {
on(el, "click", function (e) {
e.preventDefault();
go("blog-detail", { slug: el.getAttribute("data-blog") });
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-dest-filter]"), function (el) {
on(el, "click", function () {
destFilter = el.getAttribute("data-dest-filter");
render();
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-visa-filter]"), function (el) {
on(el, "click", function () {
visaFilter = el.getAttribute("data-visa-filter");
render();
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-fav]"), function (el) {
on(el, "click", function () { toggleFav(el.getAttribute("data-fav")); });
});
Array.prototype.forEach.call(document.querySelectorAll("[data-life]"), function (el) {
on(el, "click", function () {
lifeActive = Number(el.getAttribute("data-life")) || 0;
render();
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-voice]"), function (el) {
on(el, "click", function () {
voiceActive = Number(el.getAttribute("data-voice")) || 0;
render();
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-faq]"), function (el) {
on(el, "click", function () {
var id = el.getAttribute("data-faq");
faqActive = faqActive === id ? null : id;
render();
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-scroll]"), function (el) {
on(el, "click", function (e) {
e.preventDefault();
var id = el.getAttribute("data-scroll");
var node = document.getElementById(id);
if (node) node.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
var search = document.getElementById("dest-search");
if (search) {
on(search, "change", function () { destSearch = search.value || ""; render(); });
on(search, "keydown", function (e) {
if (e.key === "Enter") { destSearch = search.value || ""; render(); }
});
}
var sort = document.getElementById("dest-sort");
if (sort) on(sort, "change", function () { destSort = sort.value; render(); });
var cRun = document.getElementById("c-run");
if (cRun) {
on(cRun, "click", function () {
function n(id) {
var el = document.getElementById(id);
return Number(el && el.value) || 0;
}
var out = document.getElementById("c-out");
if (out) out.textContent = "预估月开销约 ¥" + (n("c-house") + n("c-food") + n("c-other"));
});
}
}
var typeTimer = null;
function startTypewriter() {
var el = document.getElementById("typewriter");
if (!el) return;
var PHRASES = [
"数字游民不是逃离生活,而是用科技重新定义生活。",
"在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️",
"世界那么大,你的办公室可以在任何地方 🌍",
"远程工作 + 全球旅居 = 无限可能 ✨",
];
var phraseIdx = 0, charIdx = 0, deleting = false;
if (typeTimer) clearTimeout(typeTimer);
function type() {
var current = PHRASES[phraseIdx];
if (!deleting) {
el.textContent = current.substring(0, charIdx + 1);
charIdx++;
if (charIdx === current.length) { deleting = true; typeTimer = setTimeout(type, 2500); return; }
typeTimer = setTimeout(type, 60);
} else {
el.textContent = current.substring(0, charIdx - 1);
charIdx--;
if (charIdx === 0) { deleting = false; phraseIdx = (phraseIdx + 1) % PHRASES.length; }
typeTimer = setTimeout(type, 30);
}
}
typeTimer = setTimeout(type, 400);
}
function parseHash() {
var h = (location.hash || "#/").replace(/^#\/?/, "");
var parts = h.split("/");
if (!parts[0] || parts[0] === "") return go("home");
if (parts[0] === "destination" && parts[1]) return go("destination", { slug: parts[1] });
if (parts[0] === "blog" && parts[1]) return go("blog-detail", { slug: parts[1] });
return go(parts[0]);
}
on(backBtn, "click", function () {
if (history.length > 1) history.back();
else go("home");
});
on(window, "hashchange", parseHash);
// enrich stats like H5 API
if (!D.stats) D.stats = {};
D.stats.total_nomads = D.stats.total_nomads || "35.6M";
D.stats.countries = D.stats.countries || 195;
D.stats.satisfaction = D.stats.satisfaction || 87;
storageGet(FAV_KEY, []).then(function (v) {
favs = Array.isArray(v) ? v : [];
if (location.hash && location.hash.length > 2) parseHash();
else render();
});
})();