806 lines
35 KiB
JavaScript
806 lines
35 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
var DATA = window.NOMADRO_DATA || {};
|
||
var Bridge = window.XhsBridge || window.NomadroXhs || null;
|
||
var FAV_KEY = "nomadro_xhs_favs";
|
||
var PLAN_KEY = "nomadro_xhs_plan";
|
||
var CHECK_KEY = "nomadro_xhs_checklist";
|
||
var RECENT_KEY = "nomadro_xhs_recent";
|
||
|
||
var state = {
|
||
tab: "home",
|
||
stack: [],
|
||
favs: [],
|
||
plan: [],
|
||
checklist: {},
|
||
recent: [],
|
||
region: "all",
|
||
lifeIdx: 0,
|
||
compareA: "",
|
||
compareB: "",
|
||
matchBudget: 6000,
|
||
matchClimate: "any",
|
||
matchRegion: "all",
|
||
};
|
||
|
||
var main = document.getElementById("main");
|
||
var topBrand = document.getElementById("top-brand");
|
||
var topSub = document.getElementById("top-sub");
|
||
var btnBack = document.getElementById("btn-back");
|
||
var tabbar = document.getElementById("tabbar");
|
||
var sheet = document.getElementById("sheet");
|
||
var sheetBody = document.getElementById("sheet-body");
|
||
|
||
function on(el, ev, fn) {
|
||
if (el) el.addEventListener(ev, fn);
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s == null ? "" : s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function stars(n) {
|
||
var s = "";
|
||
var i;
|
||
for (i = 0; i < 5; i++) s += i < Math.round(n || 0) ? "★" : "☆";
|
||
return s;
|
||
}
|
||
|
||
function storageGet(key, fallback) {
|
||
if (Bridge && typeof Bridge.getLocalData === "function") {
|
||
return Bridge.getLocalData(key).then(function (v) {
|
||
return v == null ? fallback : v;
|
||
});
|
||
}
|
||
try {
|
||
var raw = localStorage.getItem(key);
|
||
return Promise.resolve(raw ? JSON.parse(raw) : fallback);
|
||
} catch (e) {
|
||
return Promise.resolve(fallback);
|
||
}
|
||
}
|
||
|
||
function storageSet(key, val) {
|
||
if (Bridge && typeof Bridge.setLocalData === "function") {
|
||
return Bridge.setLocalData(key, val);
|
||
}
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify(val));
|
||
return Promise.resolve(true);
|
||
} catch (e) {
|
||
return Promise.resolve(false);
|
||
}
|
||
}
|
||
|
||
function destBySlug(slug) {
|
||
var list = DATA.destinations || [];
|
||
var i;
|
||
for (i = 0; i < list.length; i++) if (list[i].slug === slug) return list[i];
|
||
return null;
|
||
}
|
||
|
||
function isFav(slug) {
|
||
return state.favs.indexOf(slug) >= 0;
|
||
}
|
||
|
||
function toggleFav(slug) {
|
||
var i = state.favs.indexOf(slug);
|
||
if (i >= 0) state.favs.splice(i, 1);
|
||
else state.favs.push(slug);
|
||
storageSet(FAV_KEY, state.favs);
|
||
render();
|
||
}
|
||
|
||
function pushRecent(slug) {
|
||
var next = [slug].concat(state.recent.filter(function (s) { return s !== slug; })).slice(0, 8);
|
||
state.recent = next;
|
||
storageSet(RECENT_KEY, next);
|
||
}
|
||
|
||
function setChrome(title, sub, showBack) {
|
||
topBrand.textContent = title || "nomadro";
|
||
topSub.textContent = sub || "离线镜像";
|
||
if (showBack) btnBack.classList.remove("is-hidden");
|
||
else btnBack.classList.add("is-hidden");
|
||
}
|
||
|
||
function goTab(tab) {
|
||
state.tab = tab;
|
||
state.stack = [];
|
||
Array.prototype.forEach.call(tabbar.querySelectorAll(".tab"), function (btn) {
|
||
btn.classList.toggle("is-active", btn.getAttribute("data-tab") === tab);
|
||
});
|
||
render();
|
||
}
|
||
|
||
function openView(view, params) {
|
||
state.stack.push({ view: view, params: params || {} });
|
||
render();
|
||
}
|
||
|
||
function goBack() {
|
||
if (state.stack.length) {
|
||
state.stack.pop();
|
||
render();
|
||
return;
|
||
}
|
||
goTab("home");
|
||
}
|
||
|
||
function currentRoute() {
|
||
if (state.stack.length) return state.stack[state.stack.length - 1];
|
||
return { view: state.tab, params: {} };
|
||
}
|
||
|
||
function openSheet(html) {
|
||
sheetBody.innerHTML = html;
|
||
sheet.classList.remove("is-hidden");
|
||
}
|
||
|
||
function closeSheet() {
|
||
sheet.classList.add("is-hidden");
|
||
sheetBody.innerHTML = "";
|
||
}
|
||
|
||
function mdLite(src) {
|
||
var text = String(src || "");
|
||
text = esc(text);
|
||
text = text.replace(/^### (.*)$/gm, "<h3>$1</h3>");
|
||
text = text.replace(/^## (.*)$/gm, "<h2>$1</h2>");
|
||
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||
text = text.replace(/^- (.*)$/gm, "<li>$1</li>");
|
||
text = text.replace(/(<li>.*<\/li>\n?)+/g, function (m) {
|
||
return "<ul>" + m + "</ul>";
|
||
});
|
||
text = text.replace(/\n\n/g, "</p><p>");
|
||
return "<p>" + text + "</p>";
|
||
}
|
||
|
||
/* ---------- views ---------- */
|
||
|
||
function viewHome() {
|
||
setChrome("nomadro", "用一行代码环游世界", false);
|
||
var stats = DATA.stats || {};
|
||
var dests = (DATA.destinations || []).slice().sort(function (a, b) { return (b.rating || 0) - (a.rating || 0); }).slice(0, 4);
|
||
var voices = (DATA.testimonials || []).slice(0, 3);
|
||
var life = DATA.lifestyle || [];
|
||
var lifeActive = life[state.lifeIdx] || life[0];
|
||
var ticker = (DATA.ticker || []).join(" · ");
|
||
|
||
return (
|
||
'<section class="hero">' +
|
||
'<div class="hero-badge">🌏 全球数字游民正在路上</div>' +
|
||
"<h1>用一行代码<br/>环游世界</h1>" +
|
||
"<p>" + esc(DATA.offlineNote || "") + "</p>" +
|
||
'<div class="hero-actions">' +
|
||
'<button type="button" class="btn" data-go="explore">从城市开始</button>' +
|
||
'<button type="button" class="btn ghost" data-open="matcher">🎯 智能匹配</button>' +
|
||
"</div>" +
|
||
"</section>" +
|
||
(ticker ? '<p class="note">' + esc(ticker) + "</p>" : "") +
|
||
'<div class="stats">' +
|
||
'<div class="stat"><b>' + (stats.destinations || "—") + "</b><span>目的地</span></div>" +
|
||
'<div class="stat"><b>' + (stats.visas || "—") + "</b><span>签证</span></div>" +
|
||
'<div class="stat"><b>' + (stats.meetups || "—") + "</b><span>活动</span></div>" +
|
||
'<div class="stat"><b>' + (stats.blog || "—") + "</b><span>文章</span></div>" +
|
||
"</div>" +
|
||
'<div class="section"><div class="section-head"><h2>⚡ 三环路径</h2></div>' +
|
||
'<div class="ring-grid">' +
|
||
'<button type="button" class="ring-card" data-go="explore"><span class="emoji">🧭</span><strong>探索 Explore</strong><span>目的地 · 签证 · 匹配</span></button>' +
|
||
'<button type="button" class="ring-card" data-go="connect"><span class="emoji">🤝</span><strong>连接 Connect</strong><span>活动 · 社区 · 心声</span></button>' +
|
||
'<button type="button" class="ring-card" data-go="grow"><span class="emoji">🌱</span><strong>成长 Grow</strong><span>学院 · 博客 · 工具</span></button>' +
|
||
'<button type="button" class="ring-card" data-open="plan"><span class="emoji">🗓️</span><strong>旅居计划</strong><span>离线清单与收藏</span></button>' +
|
||
"</div></div>" +
|
||
'<div class="section"><div class="section-head"><h2>🌴 热门目的地</h2><button type="button" class="more" data-open="destinations">全部 →</button></div>' +
|
||
'<div class="list">' + dests.map(destCard).join("") + "</div></div>" +
|
||
'<div class="section"><div class="section-head"><h2>🗓️ 游民一天</h2></div>' +
|
||
'<div class="lifestyle">' +
|
||
life.map(function (item, idx) {
|
||
return '<button type="button" class="life-card' + (idx === state.lifeIdx ? " is-on" : "") + '" data-life="' + idx + '">' +
|
||
'<span class="emoji">' + esc(item.emoji) + "</span><strong>" + esc(item.title) + "</strong></button>";
|
||
}).join("") +
|
||
"</div>" +
|
||
(lifeActive ? '<div class="life-detail">' + esc(lifeActive.body) + "</div>" : "") +
|
||
"</div>" +
|
||
'<div class="section"><div class="section-head"><h2>🗣️ 游民心声</h2><button type="button" class="more" data-open="voices">更多 →</button></div>' +
|
||
voices.map(function (t) {
|
||
return '<div class="quote"><p>' + esc(t.avatar || "") + " " + esc(t.content) + '</p><div class="who">' +
|
||
esc(t.author) + " · " + esc(t.role) + " · " + stars(t.rating) + "</div></div>";
|
||
}).join("") +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function destCard(d) {
|
||
return (
|
||
'<button type="button" class="list-item" data-open="destination" data-slug="' + esc(d.slug) + '">' +
|
||
'<div class="lead">' + esc(d.emoji || "🌍") + "</div>" +
|
||
'<div class="body"><div class="title">' + esc(d.name) +
|
||
(isFav(d.slug) ? '<span class="fav-dot">★</span>' : "") +
|
||
'</div><div class="meta">' + esc(d.country) + " · ¥" + (d.cost || "—") + "/月 · ★" + (d.rating || "—") +
|
||
'</div><div class="meta">' + esc(d.tag || d.tagline || "") + "</div></div></button>"
|
||
);
|
||
}
|
||
|
||
function viewExploreHub() {
|
||
setChrome("探索", "Discover where to go", false);
|
||
var rings = (DATA.rings && DATA.rings.explore) || [];
|
||
return (
|
||
'<p class="note">探索环:发现去哪。离线可浏览目的地、签证,并做本地智能匹配。</p>' +
|
||
'<div class="ring-grid">' +
|
||
rings.map(function (r) {
|
||
return '<button type="button" class="ring-card" data-open="' + esc(r.id) + '"><span class="emoji">' +
|
||
esc(r.emoji) + "</span><strong>" + esc(r.title) + "</strong><span>" + esc(r.desc) + "</span></button>";
|
||
}).join("") +
|
||
"</div>" +
|
||
'<div class="section"><div class="section-head"><h2>🗺️ 游民地图快照</h2></div>' + mapBoard() + "</div>"
|
||
);
|
||
}
|
||
|
||
function mapBoard() {
|
||
var list = DATA.destinations || [];
|
||
var pins = list.map(function (d) {
|
||
var left = Math.max(8, Math.min(92, ((d.map_x || 500) / 900) * 100));
|
||
var top = Math.max(12, Math.min(88, ((d.map_y || 220) / 400) * 100));
|
||
return '<button type="button" class="map-pin" style="left:' + left + "%;top:" + top + '%" data-open="destination" data-slug="' +
|
||
esc(d.slug) + '">' + esc(d.emoji || "📍") + "<span>" + esc(d.name) + "</span></button>";
|
||
}).join("");
|
||
return '<div class="map-board">' + pins + "</div>";
|
||
}
|
||
|
||
function viewDestinations() {
|
||
setChrome("目的地", "Top nomad cities", true);
|
||
var regions = [
|
||
{ id: "all", label: "全部" },
|
||
{ id: "sea", label: "东南亚" },
|
||
{ id: "europe", label: "欧洲" },
|
||
{ id: "asia", label: "东亚" },
|
||
{ id: "latam", label: "拉美" },
|
||
];
|
||
var list = (DATA.destinations || []).filter(function (d) {
|
||
return state.region === "all" || d.region === state.region;
|
||
});
|
||
return (
|
||
'<div class="chips">' +
|
||
regions.map(function (r) {
|
||
return '<button type="button" class="chip-btn' + (state.region === r.id ? " is-on" : "") +
|
||
'" data-region="' + r.id + '">' + r.label + "</button>";
|
||
}).join("") +
|
||
"</div>" +
|
||
'<div class="list">' + list.map(destCard).join("") + "</div>"
|
||
);
|
||
}
|
||
|
||
function viewDestination(params) {
|
||
var d = destBySlug(params.slug);
|
||
if (!d) return '<p class="empty">未找到城市</p>';
|
||
pushRecent(d.slug);
|
||
setChrome(d.name, d.country, true);
|
||
var fav = isFav(d.slug);
|
||
return (
|
||
'<div class="detail-hero">' +
|
||
'<div class="emoji">' + esc(d.emoji || "🌍") + "</div>" +
|
||
"<h1>" + esc(d.name) + "</h1>" +
|
||
'<p class="meta">' + esc(d.tag) + " · " + esc(d.nomads_count || "") + " 游民</p>" +
|
||
"<p style=\"margin-top:10px;font-size:13px;line-height:1.6;color:#d9d2e8\">" + esc(d.description) + "</p>" +
|
||
'<div class="hero-actions">' +
|
||
'<button type="button" class="btn sm" data-fav="' + esc(d.slug) + '">' + (fav ? "取消收藏" : "收藏城市") + "</button>" +
|
||
'<button type="button" class="btn ghost sm" data-plan-add="' + esc(d.slug) + '">加入计划</button>' +
|
||
"</div>" +
|
||
"</div>" +
|
||
'<div class="kv">' +
|
||
'<div class="cell"><div class="k">月生活费</div><span class="v">¥' + (d.cost || "—") + "</span></div>" +
|
||
'<div class="cell"><div class="k">网速</div><span class="v">' + (d.speed || "—") + " Mbps</span></div>" +
|
||
'<div class="cell"><div class="k">均温</div><span class="v">' + (d.temperature || "—") + "°C</span></div>" +
|
||
'<div class="cell"><div class="k">评分</div><span class="v">' + (d.rating || "—") + "</span></div>" +
|
||
"</div>" +
|
||
'<div class="hl">' + (d.highlights || []).map(function (h) { return "<span>" + esc(h) + "</span>"; }).join("") + "</div>" +
|
||
'<div class="section-head"><h2>接下来</h2></div>' +
|
||
'<div class="ring-grid">' +
|
||
'<button type="button" class="ring-card" data-open="visas"><span class="emoji">📋</span><strong>签证指南</strong><span>查远程友好签证</span></button>' +
|
||
'<button type="button" class="ring-card" data-open="meetups"><span class="emoji">🎉</span><strong>同城活动</strong><span>先线下遇见</span></button>' +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function viewVisas() {
|
||
setChrome("签证指南", "Remote-friendly visas", true);
|
||
return (
|
||
'<p class="note">离线快照,政策可能变动。出发前请再核对官网。</p>' +
|
||
'<div class="list">' +
|
||
(DATA.visas || []).map(function (v) {
|
||
return '<div class="list-item"><div class="lead">' + esc(v.flag || "🛂") + '</div><div class="body">' +
|
||
'<div class="title">' + esc(v.name) + " · " + esc(v.badge) + "</div>" +
|
||
'<div class="meta">' + esc(v.duration) + " · 收入 " + esc(v.income_req) + " · 审批 " + esc(v.approval_time) + "</div>" +
|
||
'<div class="meta">' + esc(v.extra) + " · 难度 " + esc(v.difficulty_label) + "</div>" +
|
||
"</div></div>";
|
||
}).join("") +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function viewMatcher() {
|
||
setChrome("智能匹配", "Find your city", true);
|
||
var scored = scoreDestinations();
|
||
return (
|
||
'<div class="form">' +
|
||
'<div class="field"><label>月预算(元)</label><input id="m-budget" type="number" value="' + state.matchBudget + '" /></div>' +
|
||
'<div class="field"><label>气候偏好</label><select id="m-climate">' +
|
||
opt("any", "不限", state.matchClimate) +
|
||
opt("warm", "偏暖 ≥24°C", state.matchClimate) +
|
||
opt("mild", "温和 16–24°C", state.matchClimate) +
|
||
opt("cool", "偏凉 ≤16°C", state.matchClimate) +
|
||
"</select></div>" +
|
||
'<div class="field"><label>区域</label><select id="m-region">' +
|
||
opt("all", "全球", state.matchRegion) +
|
||
opt("sea", "东南亚", state.matchRegion) +
|
||
opt("europe", "欧洲", state.matchRegion) +
|
||
opt("asia", "东亚", state.matchRegion) +
|
||
opt("latam", "拉美", state.matchRegion) +
|
||
"</select></div>" +
|
||
'<button type="button" class="btn" id="m-run" style="width:100%">重新匹配</button>' +
|
||
"</div>" +
|
||
'<div class="section"><div class="section-head"><h2>推荐结果</h2></div><div class="list">' +
|
||
scored.slice(0, 6).map(function (item) {
|
||
var d = item.d;
|
||
return '<button type="button" class="list-item" data-open="destination" data-slug="' + esc(d.slug) + '">' +
|
||
'<div class="lead">' + esc(d.emoji) + '</div><div class="body"><div class="title">' + esc(d.name) +
|
||
' · 匹配 ' + item.score + '</div><div class="meta">¥' + d.cost + "/月 · " + d.speed + "Mbps · ★" + d.rating +
|
||
"</div></div></button>";
|
||
}).join("") +
|
||
"</div></div>"
|
||
);
|
||
}
|
||
|
||
function opt(v, label, cur) {
|
||
return '<option value="' + v + '"' + (cur === v ? " selected" : "") + ">" + label + "</option>";
|
||
}
|
||
|
||
function scoreDestinations() {
|
||
var budget = Number(state.matchBudget) || 999999;
|
||
return (DATA.destinations || []).map(function (d) {
|
||
var score = 50;
|
||
if (d.cost <= budget) score += 25;
|
||
else score -= Math.min(30, (d.cost - budget) / 400);
|
||
if (state.matchRegion !== "all" && d.region !== state.matchRegion) score -= 40;
|
||
var t = d.temperature || 20;
|
||
if (state.matchClimate === "warm" && t >= 24) score += 15;
|
||
if (state.matchClimate === "mild" && t >= 16 && t < 24) score += 15;
|
||
if (state.matchClimate === "cool" && t <= 16) score += 15;
|
||
score += (d.rating || 0) * 3;
|
||
score += Math.min(15, (d.speed || 0) / 15);
|
||
return { d: d, score: Math.round(score) };
|
||
}).sort(function (a, b) { return b.score - a.score; });
|
||
}
|
||
|
||
function viewCompare() {
|
||
setChrome("城市对比", "Side by side", true);
|
||
var list = DATA.destinations || [];
|
||
if (!state.compareA && list[0]) state.compareA = list[0].slug;
|
||
if (!state.compareB && list[1]) state.compareB = list[1].slug;
|
||
var a = destBySlug(state.compareA) || list[0];
|
||
var b = destBySlug(state.compareB) || list[1];
|
||
var options = list.map(function (d) {
|
||
return '<option value="' + esc(d.slug) + '">' + esc(d.name) + "</option>";
|
||
}).join("");
|
||
return (
|
||
'<div class="compare-pick">' +
|
||
'<select id="cmp-a">' + options.replace('value="' + esc(a.slug) + '"', 'value="' + esc(a.slug) + '" selected') + "</select>" +
|
||
'<select id="cmp-b">' + options.replace('value="' + esc(b.slug) + '"', 'value="' + esc(b.slug) + '" selected') + "</select>" +
|
||
"</div>" +
|
||
'<table class="compare-table"><tbody>' +
|
||
row("城市", (a.emoji || "") + " " + a.name, (b.emoji || "") + " " + b.name) +
|
||
row("国家", a.country, b.country) +
|
||
row("月生活费", "¥" + a.cost, "¥" + b.cost) +
|
||
row("网速", a.speed + " Mbps", b.speed + " Mbps") +
|
||
row("均温", a.temperature + "°C", b.temperature + "°C") +
|
||
row("评分", a.rating, b.rating) +
|
||
row("游民规模", a.nomads_count, b.nomads_count) +
|
||
"</tbody></table>"
|
||
);
|
||
}
|
||
|
||
function row(k, a, b) {
|
||
return "<tr><th>" + esc(k) + "</th><td>" + esc(a) + "</td><td>" + esc(b) + "</td></tr>";
|
||
}
|
||
|
||
function viewConnectHub() {
|
||
setChrome("连接", "Meet fellow nomads", false);
|
||
var rings = (DATA.rings && DATA.rings.connect) || [];
|
||
return (
|
||
'<p class="note">连接环:遇见同行。离线可浏览活动与讨论;报名/发帖请到网页版。</p>' +
|
||
'<div class="ring-grid">' +
|
||
rings.map(function (r) {
|
||
return '<button type="button" class="ring-card" data-open="' + esc(r.id) + '"><span class="emoji">' +
|
||
esc(r.emoji) + "</span><strong>" + esc(r.title) + "</strong><span>" + esc(r.desc) + "</span></button>";
|
||
}).join("") +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function viewMeetups() {
|
||
setChrome("游民活动", "Events snapshot", true);
|
||
return (
|
||
'<div class="list">' +
|
||
(DATA.meetups || []).map(function (m) {
|
||
return '<div class="list-item"><div class="lead">' + esc(m.emoji || "🎉") + '</div><div class="body">' +
|
||
'<div class="title">' + esc(m.title) + "</div>" +
|
||
'<div class="meta">' + esc(m.city) + " · " + esc(m.date) + " " + esc(m.time || "") + " · " +
|
||
esc(m.venue || "") + "</div>" +
|
||
'<div class="meta">' + esc(m.description || "") + "</div>" +
|
||
'<div class="meta">' + (m.rsvp_count || 0) + "/" + (m.max_attendees || "—") + " · " +
|
||
esc((m.tags || []).join(" · ")) + "</div>" +
|
||
"</div></div>";
|
||
}).join("") +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function viewCommunity() {
|
||
setChrome("社区讨论", "Offline threads", true);
|
||
return (
|
||
'<div class="list">' +
|
||
(DATA.discussions || []).map(function (d) {
|
||
return '<button type="button" class="list-item" data-open="discussion" data-id="' + esc(d.id) + '">' +
|
||
'<div class="lead">' + esc(d.author_emoji || "💬") + '</div><div class="body">' +
|
||
'<div class="title">' + (d.is_pinned ? "📌 " : "") + esc(d.title) + "</div>" +
|
||
'<div class="meta">' + esc(d.excerpt) + "</div>" +
|
||
'<div class="meta">' + esc(d.author) + " · " + (d.reply_count || 0) + " 回复 · ♥ " + (d.like_count || 0) + "</div>" +
|
||
"</div></button>";
|
||
}).join("") +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function viewDiscussion(params) {
|
||
var list = DATA.discussions || [];
|
||
var d = null;
|
||
var i;
|
||
for (i = 0; i < list.length; i++) if (list[i].id === params.id) d = list[i];
|
||
if (!d) return '<p class="empty">讨论不存在</p>';
|
||
setChrome("讨论", d.category || "community", true);
|
||
var detail = (DATA.discussionDetails && DATA.discussionDetails[d.id]) || null;
|
||
var replies = (detail && detail.replies) || [];
|
||
return (
|
||
'<div class="detail-hero">' +
|
||
"<h1 style=\"font-size:18px\">" + esc(d.title) + "</h1>" +
|
||
'<p class="meta" style="margin-top:8px">' + esc(d.author) + " · " + esc(d.created_at || "") + "</p>" +
|
||
"<p style=\"margin-top:12px;font-size:14px;line-height:1.65\">" + esc(d.excerpt) + "</p>" +
|
||
"</div>" +
|
||
(replies.length
|
||
? '<div class="section"><div class="section-head"><h2>回复</h2></div>' +
|
||
replies.map(function (r) {
|
||
return '<div class="quote"><p>' + esc(r.content || r.body || "") + '</p><div class="who">' +
|
||
esc(r.author || "") + "</div></div>";
|
||
}).join("") + "</div>"
|
||
: '<p class="note">离线包仅含帖子摘要。完整回复请到网页版社区。</p>')
|
||
);
|
||
}
|
||
|
||
function viewVoices() {
|
||
setChrome("游民心声", "Real stories", true);
|
||
return (DATA.testimonials || []).map(function (t) {
|
||
return '<div class="quote"><p>' + esc(t.avatar || "") + " " + esc(t.content) + '</p><div class="who">' +
|
||
esc(t.author) + " · " + esc(t.role) + " · " + stars(t.rating) + "</div></div>";
|
||
}).join("");
|
||
}
|
||
|
||
function viewGrowHub() {
|
||
setChrome("成长", "Learn on the road", false);
|
||
var rings = (DATA.rings && DATA.rings.grow) || [];
|
||
return (
|
||
'<p class="note">成长环:路上做事。学院、博客与工具均可离线浏览。</p>' +
|
||
'<div class="ring-grid">' +
|
||
rings.map(function (r) {
|
||
return '<button type="button" class="ring-card" data-open="' + esc(r.id) + '"><span class="emoji">' +
|
||
esc(r.emoji) + "</span><strong>" + esc(r.title) + "</strong><span>" + esc(r.desc) + "</span></button>";
|
||
}).join("") +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function viewDigital() {
|
||
setChrome("游民学院", "Digital academy", true);
|
||
var modules = DATA.course || [];
|
||
return modules.map(function (mod, mi) {
|
||
return '<div class="section"><div class="section-head"><h2>' + esc(mod.title) + "</h2></div><div class=\"list\">" +
|
||
(mod.lessons || []).map(function (les, li) {
|
||
var key = mi + "-" + li;
|
||
return '<button type="button" class="list-item" data-open="lesson" data-key="' + key + '">' +
|
||
'<div class="lead">' + (les.free ? "🆓" : "🔒") + '</div><div class="body">' +
|
||
'<div class="title">' + esc(les.title) + "</div>" +
|
||
'<div class="meta">' + esc(les.duration) + (les.free ? " · 免费" : " · 网页版 VIP") + "</div>" +
|
||
"</div></button>";
|
||
}).join("") + "</div></div>";
|
||
}).join("");
|
||
}
|
||
|
||
function viewLesson(params) {
|
||
var lesson = (DATA.lessons || {})[params.key];
|
||
if (!lesson) return '<p class="empty">课程不存在</p>';
|
||
setChrome(lesson.title, "Lesson", true);
|
||
return (
|
||
'<div class="detail-hero">' +
|
||
"<h1 style=\"font-size:18px\">" + esc(lesson.title) + "</h1>" +
|
||
'<p class="meta" style="margin-top:8px">' + esc(lesson.duration) + (lesson.free ? " · 免费" : " · VIP 内容离线摘要") + "</p>" +
|
||
"</div>" +
|
||
'<div class="article">' + esc(lesson.content || "") + "</div>"
|
||
);
|
||
}
|
||
|
||
function viewBlog() {
|
||
setChrome("博客", "Guides & stories", true);
|
||
return (
|
||
'<div class="list">' +
|
||
(DATA.blog || []).map(function (b) {
|
||
return '<button type="button" class="list-item" data-open="blog-detail" data-slug="' + esc(b.slug) + '">' +
|
||
'<div class="lead">' + esc(b.emoji || "📝") + '</div><div class="body">' +
|
||
'<div class="title">' + esc(b.title) + "</div>" +
|
||
'<div class="meta">' + esc(b.excerpt) + "</div>" +
|
||
'<div class="meta">' + esc(b.author) + " · " + esc(b.published_at) + " · " + (b.read_time || "?") + " 分钟</div>" +
|
||
"</div></button>";
|
||
}).join("") +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function viewBlogDetail(params) {
|
||
var list = DATA.blog || [];
|
||
var b = null;
|
||
var i;
|
||
for (i = 0; i < list.length; i++) if (list[i].slug === params.slug) b = list[i];
|
||
if (!b) return '<p class="empty">文章不存在</p>';
|
||
setChrome("博客", b.slug, true);
|
||
var content = (DATA.blogContent && DATA.blogContent[b.slug]) || b.excerpt || "";
|
||
return (
|
||
'<div class="detail-hero">' +
|
||
'<div class="emoji">' + esc(b.emoji || "📝") + "</div>" +
|
||
"<h1 style=\"font-size:20px\">" + esc(b.title) + "</h1>" +
|
||
'<p class="meta" style="margin-top:8px">' + esc(b.author) + " · " + esc(b.published_at) + " · " +
|
||
(b.read_time || "?") + " 分钟 · " + esc((b.tags || []).join(" · ")) + "</p>" +
|
||
"</div>" +
|
||
'<article class="article">' + mdLite(content) + "</article>"
|
||
);
|
||
}
|
||
|
||
function viewTools() {
|
||
setChrome("工具箱", "Offline utilities", true);
|
||
return (
|
||
'<div class="section"><div class="section-head"><h2>实用工具</h2></div>' +
|
||
'<div class="ring-grid">' +
|
||
'<button type="button" class="ring-card" data-open="calc"><span class="emoji">🧮</span><strong>月费用估算</strong><span>住宿+餐饮+交通</span></button>' +
|
||
'<button type="button" class="ring-card" data-open="plan"><span class="emoji">✅</span><strong>落地清单</strong><span>第一月 checklist</span></button>' +
|
||
"</div></div>" +
|
||
'<div class="section"><div class="section-head"><h2>推荐软件栈</h2></div><div class="list">' +
|
||
(DATA.tools || []).map(function (t) {
|
||
return '<div class="list-item"><div class="lead">' + esc(t.emoji) + '</div><div class="body">' +
|
||
'<div class="title">' + esc(t.name) + "</div>" +
|
||
'<div class="meta">' + esc(t.description) + "</div>" +
|
||
'<div class="meta">' + esc((t.tags || []).join(" · ")) + "</div></div></div>";
|
||
}).join("") +
|
||
"</div></div>"
|
||
);
|
||
}
|
||
|
||
function viewCalc() {
|
||
setChrome("月费用估算", "Cost calculator", true);
|
||
return (
|
||
'<div class="form">' +
|
||
'<div class="field"><label>住宿</label><input id="c-house" type="number" value="2500" /></div>' +
|
||
'<div class="field"><label>餐饮</label><input id="c-food" type="number" value="1500" /></div>' +
|
||
'<div class="field"><label>交通</label><input id="c-trans" type="number" value="400" /></div>' +
|
||
'<div class="field"><label>联合办公</label><input id="c-cowork" type="number" value="500" /></div>' +
|
||
'<div class="field"><label>其他</label><input id="c-other" type="number" value="600" /></div>' +
|
||
'<button type="button" class="btn" id="c-run" style="width:100%">计算</button>' +
|
||
'<p class="note" id="c-out" style="margin-top:12px">合计将显示在这里</p>' +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function viewFaq() {
|
||
setChrome("常见问题", "FAQ", true);
|
||
return (DATA.faqs || []).map(function (f) {
|
||
return '<div class="quote"><p><strong>' + esc(f.question) + "</strong></p><p style=\"margin-top:8px\">" +
|
||
esc(f.answer) + "</p></div>";
|
||
}).join("");
|
||
}
|
||
|
||
function viewPlan() {
|
||
setChrome("旅居计划", "Offline plan", true);
|
||
var checks = DATA.planChecklist || [];
|
||
var planCities = state.plan.map(function (slug) {
|
||
var d = destBySlug(slug);
|
||
if (!d) return "";
|
||
return destCard(d);
|
||
}).join("");
|
||
return (
|
||
'<p class="note">计划与清单仅保存在本机(小红书 Storage / localStorage)。</p>' +
|
||
'<div class="section"><div class="section-head"><h2>计划中的城市</h2></div>' +
|
||
(planCities || '<p class="empty">还没有城市,去目的地页点「加入计划」</p>') +
|
||
"</div>" +
|
||
'<div class="section"><div class="section-head"><h2>落地清单</h2></div><div class="check-list">' +
|
||
checks.map(function (c) {
|
||
var on = !!state.checklist[c.id];
|
||
return '<button type="button" class="check-item' + (on ? " is-on" : "") + '" data-check="' + esc(c.id) + '">' +
|
||
'<span class="check-box">' + (on ? "✓" : "") + "</span><span>" + esc(c.label) + "</span></button>";
|
||
}).join("") +
|
||
"</div></div>"
|
||
);
|
||
}
|
||
|
||
function viewMine() {
|
||
setChrome("我的", "Favorites & plan", false);
|
||
var favCards = state.favs.map(function (slug) {
|
||
var d = destBySlug(slug);
|
||
return d ? destCard(d) : "";
|
||
}).join("");
|
||
var recent = state.recent.map(function (slug) {
|
||
var d = destBySlug(slug);
|
||
return d ? destCard(d) : "";
|
||
}).join("");
|
||
return (
|
||
'<p class="note">' + esc(DATA.offlineNote || "") + "</p>" +
|
||
'<div class="ring-grid">' +
|
||
'<button type="button" class="ring-card" data-open="plan"><span class="emoji">🗓️</span><strong>旅居计划</strong><span>清单与城市</span></button>' +
|
||
'<button type="button" class="ring-card" data-open="matcher"><span class="emoji">🎯</span><strong>智能匹配</strong><span>重算推荐</span></button>' +
|
||
"</div>" +
|
||
'<div class="section"><div class="section-head"><h2>我的收藏</h2></div>' +
|
||
(favCards || '<p class="empty">暂无收藏</p>') + "</div>" +
|
||
'<div class="section"><div class="section-head"><h2>最近浏览</h2></div>' +
|
||
(recent || '<p class="empty">还没有浏览记录</p>') + "</div>" +
|
||
'<div class="section"><div class="section-head"><h2>关于</h2></div>' +
|
||
'<div class="quote"><p>品牌 <strong>nomadro</strong> — 数字游民旅居平台离线镜像。</p>' +
|
||
'<div class="who">完整功能:' + esc(DATA.site || "https://nomadweb.nomadro.com") + "</div></div></div>"
|
||
);
|
||
}
|
||
|
||
function render() {
|
||
var route = currentRoute();
|
||
var html = "";
|
||
switch (route.view) {
|
||
case "home": html = viewHome(); break;
|
||
case "explore": html = viewExploreHub(); break;
|
||
case "connect": html = viewConnectHub(); break;
|
||
case "grow": html = viewGrowHub(); break;
|
||
case "mine": html = viewMine(); break;
|
||
case "destinations": html = viewDestinations(); break;
|
||
case "destination": html = viewDestination(route.params); break;
|
||
case "visas": html = viewVisas(); break;
|
||
case "matcher": html = viewMatcher(); break;
|
||
case "compare": html = viewCompare(); break;
|
||
case "meetups": html = viewMeetups(); break;
|
||
case "community": html = viewCommunity(); break;
|
||
case "discussion": html = viewDiscussion(route.params); break;
|
||
case "voices": html = viewVoices(); break;
|
||
case "digital": html = viewDigital(); break;
|
||
case "lesson": html = viewLesson(route.params); break;
|
||
case "blog": html = viewBlog(); break;
|
||
case "blog-detail": html = viewBlogDetail(route.params); break;
|
||
case "tools": html = viewTools(); break;
|
||
case "calc": html = viewCalc(); break;
|
||
case "faq": html = viewFaq(); break;
|
||
case "plan": html = viewPlan(); break;
|
||
default: html = viewHome();
|
||
}
|
||
main.innerHTML = html;
|
||
bindDynamic();
|
||
window.scrollTo(0, 0);
|
||
}
|
||
|
||
function bindDynamic() {
|
||
Array.prototype.forEach.call(main.querySelectorAll("[data-go]"), function (el) {
|
||
on(el, "click", function () { goTab(el.getAttribute("data-go")); });
|
||
});
|
||
Array.prototype.forEach.call(main.querySelectorAll("[data-open]"), function (el) {
|
||
on(el, "click", function () {
|
||
var view = el.getAttribute("data-open");
|
||
var params = {};
|
||
if (el.getAttribute("data-slug")) params.slug = el.getAttribute("data-slug");
|
||
if (el.getAttribute("data-id")) params.id = el.getAttribute("data-id");
|
||
if (el.getAttribute("data-key")) params.key = el.getAttribute("data-key");
|
||
openView(view, params);
|
||
});
|
||
});
|
||
Array.prototype.forEach.call(main.querySelectorAll("[data-region]"), function (el) {
|
||
on(el, "click", function () {
|
||
state.region = el.getAttribute("data-region");
|
||
render();
|
||
});
|
||
});
|
||
Array.prototype.forEach.call(main.querySelectorAll("[data-life]"), function (el) {
|
||
on(el, "click", function () {
|
||
state.lifeIdx = Number(el.getAttribute("data-life")) || 0;
|
||
render();
|
||
});
|
||
});
|
||
Array.prototype.forEach.call(main.querySelectorAll("[data-fav]"), function (el) {
|
||
on(el, "click", function () { toggleFav(el.getAttribute("data-fav")); });
|
||
});
|
||
Array.prototype.forEach.call(main.querySelectorAll("[data-plan-add]"), function (el) {
|
||
on(el, "click", function () {
|
||
var slug = el.getAttribute("data-plan-add");
|
||
if (state.plan.indexOf(slug) < 0) state.plan.push(slug);
|
||
storageSet(PLAN_KEY, state.plan);
|
||
openSheet("<p style=\"font-size:14px;line-height:1.6\">已加入旅居计划。<br/>可在「我的 → 旅居计划」查看。</p>" +
|
||
'<button type="button" class="btn" id="sheet-ok" style="width:100%;margin-top:14px">好的</button>');
|
||
on(document.getElementById("sheet-ok"), "click", closeSheet);
|
||
});
|
||
});
|
||
Array.prototype.forEach.call(main.querySelectorAll("[data-check]"), function (el) {
|
||
on(el, "click", function () {
|
||
var id = el.getAttribute("data-check");
|
||
state.checklist[id] = !state.checklist[id];
|
||
storageSet(CHECK_KEY, state.checklist);
|
||
render();
|
||
});
|
||
});
|
||
|
||
var mRun = document.getElementById("m-run");
|
||
if (mRun) {
|
||
on(mRun, "click", function () {
|
||
var b = document.getElementById("m-budget");
|
||
var c = document.getElementById("m-climate");
|
||
var r = document.getElementById("m-region");
|
||
state.matchBudget = Number(b && b.value) || 6000;
|
||
state.matchClimate = (c && c.value) || "any";
|
||
state.matchRegion = (r && r.value) || "all";
|
||
render();
|
||
});
|
||
}
|
||
|
||
var cmpA = document.getElementById("cmp-a");
|
||
var cmpB = document.getElementById("cmp-b");
|
||
if (cmpA) on(cmpA, "change", function () { state.compareA = cmpA.value; render(); });
|
||
if (cmpB) on(cmpB, "change", function () { state.compareB = cmpB.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 total = n("c-house") + n("c-food") + n("c-trans") + n("c-cowork") + n("c-other");
|
||
var out = document.getElementById("c-out");
|
||
if (out) out.textContent = "预估月开销约 ¥" + total + "(离线估算,仅供参考)";
|
||
});
|
||
}
|
||
}
|
||
|
||
function bindChrome() {
|
||
Array.prototype.forEach.call(tabbar.querySelectorAll(".tab"), function (btn) {
|
||
on(btn, "click", function () { goTab(btn.getAttribute("data-tab")); });
|
||
});
|
||
on(btnBack, "click", goBack);
|
||
on(document.getElementById("btn-offline"), "click", function () {
|
||
openSheet(
|
||
"<h3 style=\"margin-bottom:8px\">离线说明</h3>" +
|
||
"<p style=\"font-size:13px;line-height:1.65;color:#cfc9d8\">" + esc(DATA.offlineNote || "") + "</p>" +
|
||
'<button type="button" class="btn" id="sheet-ok" style="width:100%;margin-top:14px">知道了</button>'
|
||
);
|
||
on(document.getElementById("sheet-ok"), "click", closeSheet);
|
||
});
|
||
on(document.getElementById("sheet-mask"), "click", closeSheet);
|
||
}
|
||
|
||
function boot() {
|
||
bindChrome();
|
||
Promise.all([
|
||
storageGet(FAV_KEY, []),
|
||
storageGet(PLAN_KEY, []),
|
||
storageGet(CHECK_KEY, {}),
|
||
storageGet(RECENT_KEY, []),
|
||
]).then(function (vals) {
|
||
state.favs = Array.isArray(vals[0]) ? vals[0] : [];
|
||
state.plan = Array.isArray(vals[1]) ? vals[1] : [];
|
||
state.checklist = vals[2] && typeof vals[2] === "object" ? vals[2] : {};
|
||
state.recent = Array.isArray(vals[3]) ? vals[3] : [];
|
||
render();
|
||
});
|
||
}
|
||
|
||
boot();
|
||
})();
|