nomadweb/redmini/tool/app.js
eric bf7167c185 Rebuild redmini as full offline nomadro product mirror for XHS.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 13:04:35 -05:00

806 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();
})();