Rebuild redmini as full offline nomadro product mirror for XHS.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
73b0513b9c
commit
bf7167c185
@ -1,25 +1,36 @@
|
||||
# redmini · nomadro 小红书小程序(纯前端离线)
|
||||
# redmini · nomadro 小红书离线镜像(1:1 产品面)
|
||||
|
||||
参考 Desktop/redmini:小红书 **miniTool 无法联网**,本目录禁止 `fetch` / XHR / WebSocket。
|
||||
小红书 **miniTool 无法联网**。本目录是 [nomadweb.nomadro.com](https://nomadweb.nomadro.com) 的离线内容镜像:
|
||||
|
||||
数据写死在 `src/data.js`,收藏仅存本地 Storage。
|
||||
- 三环导航:探索 / 连接 / 成长(与官网一致)
|
||||
- 首页:Hero、统计、热门目的地、游民一天、游民心声
|
||||
- 探索:目的地列表/详情、地图快照、签证、智能匹配、城市对比
|
||||
- 连接:活动、社区讨论、心声
|
||||
- 成长:游民学院课时、博客全文、工具箱、费用估算、FAQ
|
||||
- 我的:收藏、最近浏览、旅居计划清单(本地 Storage)
|
||||
|
||||
## 打包
|
||||
数据来自 `backend/app/data/mock_data.py` 等官网同源 mock(打包进 `src/data.js`)。
|
||||
|
||||
## 打包上传
|
||||
|
||||
```bash
|
||||
cd redmini
|
||||
# 若 mock 有更新,先重建 data.js(在仓库根目录):
|
||||
# cd ../backend && python -c "..." # 或复用 scripts 导出
|
||||
python scripts/assemble.py
|
||||
python scripts/static_scan.py
|
||||
python scripts/pack.py
|
||||
python scripts/static_scan.py # 禁止 fetch / XHR / WebSocket
|
||||
python scripts/pack.py # -> dist/nomadro-redmini.zip
|
||||
```
|
||||
|
||||
产出:`dist/nomadro-redmini.zip`(根目录含 `index.html`),用小红书开发者工具上传。
|
||||
用小红书开发者工具上传 zip(根目录含 `index.html`)。
|
||||
|
||||
## 目录
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `src/data.js` | 离线目的地与提示文案 |
|
||||
| `tool/` | 源码 HTML/CSS/JS |
|
||||
| `src/data.js` | 离线数据包 |
|
||||
| `tool/` | HTML / CSS / JS / xhs-bridge |
|
||||
| `release/` | assemble 产物 |
|
||||
| `scripts/` | assemble / scan / pack |
|
||||
| `scripts/` | assemble / static_scan / pack |
|
||||
|
||||
本地浏览器预览:直接打开 `tool/index.html`(需先把 `src/data.js` 拷到 `tool/`,或跑 assemble 后打开 `release/index.html`)。
|
||||
|
||||
@ -1,42 +1,805 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
var data = window.NOMADRO_DATA || { destinations: [], tips: [] };
|
||||
var root = document.getElementById('app');
|
||||
var favKey = 'nomadro_redmini_favs';
|
||||
"use strict";
|
||||
|
||||
function render(favs) {
|
||||
favs = favs || [];
|
||||
var cards = (data.destinations || []).map(function (d) {
|
||||
var marked = favs.indexOf(d.slug) >= 0 ? ' · 已收藏' : '';
|
||||
return (
|
||||
'<article class="card" data-slug="' + d.slug + '">' +
|
||||
'<div class="name">' + d.name + '</div>' +
|
||||
'<div class="meta">' + (d.country || '') + ' · ¥' + (d.cost || '—') + '/月 · ★' + (d.rating || '—') + '</div>' +
|
||||
'<div class="meta">' + (d.tagline || '') + '</div>' +
|
||||
'<div class="fav">' + (marked || '点按收藏(仅本地)') + '</div>' +
|
||||
'</article>'
|
||||
);
|
||||
}).join('');
|
||||
var tips = (data.tips || []).map(function (t) { return '<li>' + t + '</li>'; }).join('');
|
||||
root.innerHTML =
|
||||
'<p class="brand">' + (data.brand || 'nomadro') + '</p>' +
|
||||
'<p class="tag">' + (data.tagline || '') + '</p>' +
|
||||
'<p class="note">' + (data.note || '') + '</p>' +
|
||||
cards +
|
||||
'<section class="tips"><h2>离线提示</h2><ul>' + tips + '</ul></section>';
|
||||
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";
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.card'), function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
var slug = el.getAttribute('data-slug');
|
||||
var next = favs.slice();
|
||||
var i = next.indexOf(slug);
|
||||
if (i >= 0) next.splice(i, 1); else next.push(slug);
|
||||
window.NomadroXhs.setLocalData(favKey, next).then(function () { render(next); });
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
window.NomadroXhs.getLocalData(favKey).then(function (favs) {
|
||||
render(Array.isArray(favs) ? favs : []);
|
||||
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();
|
||||
})();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -3,11 +3,41 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>nomadro · 离线目的地</title>
|
||||
<title>nomadro · 数字游民</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="app">
|
||||
<header class="top-chrome" id="top-chrome">
|
||||
<div class="top-left">
|
||||
<button type="button" class="icon-btn is-hidden" id="btn-back" aria-label="返回">←</button>
|
||||
<div>
|
||||
<p class="top-brand" id="top-brand">nomadro</p>
|
||||
<p class="top-sub" id="top-sub">离线镜像</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="chip" id="btn-offline" title="离线说明">离线</button>
|
||||
</header>
|
||||
|
||||
<main class="main" id="main"></main>
|
||||
|
||||
<nav class="tabbar" id="tabbar" aria-label="主导航">
|
||||
<button type="button" class="tab is-active" data-tab="home"><span>🏠</span><i>首页</i></button>
|
||||
<button type="button" class="tab" data-tab="explore"><span>🧭</span><i>探索</i></button>
|
||||
<button type="button" class="tab" data-tab="connect"><span>🤝</span><i>连接</i></button>
|
||||
<button type="button" class="tab" data-tab="grow"><span>🌱</span><i>成长</i></button>
|
||||
<button type="button" class="tab" data-tab="mine"><span>👤</span><i>我的</i></button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sheet is-hidden" id="sheet" role="dialog" aria-modal="true">
|
||||
<div class="sheet-mask" id="sheet-mask"></div>
|
||||
<div class="sheet-panel">
|
||||
<div class="sheet-handle"></div>
|
||||
<div id="sheet-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./data.js"></script>
|
||||
<script src="./xhs-bridge.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
|
||||
@ -1,12 +1,277 @@
|
||||
*{box-sizing:border-box}html,body{margin:0;padding:0;background:#0c0b12;color:#f4f0ea;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
|
||||
#app{min-height:100vh;padding:28px 20px 48px}
|
||||
.brand{font-size:28px;font-weight:700;letter-spacing:1px}
|
||||
.tag{margin:8px 0 6px;color:#a8a3b3;font-size:14px}
|
||||
.note{color:#c9a227;font-size:12px;margin-bottom:20px}
|
||||
.card{background:rgba(255,255,255,.06);border-radius:14px;padding:14px;margin-bottom:12px}
|
||||
.name{font-size:17px;font-weight:600}
|
||||
.meta{margin-top:6px;color:#a8a3b3;font-size:13px}
|
||||
.tips{margin-top:24px}
|
||||
.tips h2{font-size:16px;margin:0 0 10px}
|
||||
.tips li{margin:8px 0;color:#cfc9d8;font-size:13px;line-height:1.5}
|
||||
.fav{margin-top:10px;font-size:12px;color:#f0c674}
|
||||
:root {
|
||||
--bg: #0b0a10;
|
||||
--bg2: #14121c;
|
||||
--card: rgba(255, 255, 255, 0.06);
|
||||
--card2: rgba(255, 255, 255, 0.09);
|
||||
--text: #f4f0ea;
|
||||
--muted: #9a94a8;
|
||||
--accent: #f0c674;
|
||||
--accent2: #7dd3c7;
|
||||
--danger: #f07178;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--safe-b: env(safe-area-inset-bottom, 0px);
|
||||
--safe-t: env(safe-area-inset-top, 0px);
|
||||
--tab-h: 64px;
|
||||
--top-h: 56px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { border: 0; background: none; cursor: pointer; }
|
||||
.is-hidden { display: none !important; }
|
||||
|
||||
#app {
|
||||
min-height: 100%;
|
||||
min-height: 100vh;
|
||||
padding-top: calc(var(--top-h) + var(--safe-t));
|
||||
padding-bottom: calc(var(--tab-h) + var(--safe-b) + 8px);
|
||||
}
|
||||
|
||||
.top-chrome {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
|
||||
height: calc(var(--top-h) + var(--safe-t));
|
||||
padding: var(--safe-t) 14px 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: rgba(11, 10, 16, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.top-left { display: flex; align-items: center; gap: 8px; }
|
||||
.top-brand { font-weight: 700; font-size: 16px; letter-spacing: 0.4px; }
|
||||
.top-sub { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.icon-btn {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
background: var(--card); color: var(--text); font-size: 16px;
|
||||
}
|
||||
.chip {
|
||||
padding: 6px 10px; border-radius: 999px; font-size: 12px;
|
||||
background: rgba(240, 198, 116, 0.15); color: var(--accent);
|
||||
border: 1px solid rgba(240, 198, 116, 0.35);
|
||||
}
|
||||
|
||||
.main { padding: 12px 14px 20px; max-width: 720px; margin: 0 auto; }
|
||||
|
||||
.tabbar {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
|
||||
height: calc(var(--tab-h) + var(--safe-b));
|
||||
padding: 6px 6px var(--safe-b);
|
||||
display: flex; background: rgba(16, 14, 24, 0.96);
|
||||
border-top: 1px solid var(--border);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.tab {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
color: var(--muted); font-size: 10px; padding: 6px 0;
|
||||
}
|
||||
.tab span { font-size: 18px; line-height: 1; }
|
||||
.tab.is-active { color: var(--accent); }
|
||||
|
||||
.hero {
|
||||
position: relative; overflow: hidden; border-radius: 22px;
|
||||
padding: 22px 18px 20px;
|
||||
background:
|
||||
radial-gradient(500px 220px at 0% 0%, rgba(240, 198, 116, 0.22), transparent 55%),
|
||||
radial-gradient(420px 200px at 100% 0%, rgba(125, 211, 199, 0.16), transparent 50%),
|
||||
linear-gradient(165deg, #15121d, #0e0c14 70%);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-block; font-size: 11px; color: var(--accent2);
|
||||
background: rgba(125, 211, 199, 0.12); padding: 4px 8px; border-radius: 999px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hero h1 { font-size: 28px; line-height: 1.2; font-weight: 760; margin-bottom: 8px; }
|
||||
.hero p { color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.hero-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 10px 14px; border-radius: 999px; font-size: 13px; font-weight: 600;
|
||||
background: var(--accent); color: #1a1520;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent; color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn.sm { padding: 7px 11px; font-size: 12px; }
|
||||
|
||||
.section { margin: 18px 0 8px; }
|
||||
.section-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.section-head h2 { font-size: 16px; font-weight: 700; }
|
||||
.section-head .more { font-size: 12px; color: var(--accent); }
|
||||
|
||||
.stats {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 14px 0;
|
||||
}
|
||||
.stat {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
|
||||
padding: 10px 6px; text-align: center;
|
||||
}
|
||||
.stat b { display: block; color: var(--accent); font-size: 16px; }
|
||||
.stat span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
|
||||
.ring-grid, .card-grid { display: grid; gap: 10px; }
|
||||
.ring-grid { grid-template-columns: 1fr 1fr; }
|
||||
.ring-card, .card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 14px; text-align: left;
|
||||
}
|
||||
.ring-card .emoji, .card .emoji { font-size: 22px; display: block; margin-bottom: 8px; }
|
||||
.ring-card strong, .card strong { display: block; font-size: 14px; margin-bottom: 4px; }
|
||||
.ring-card span, .card .desc, .meta {
|
||||
display: block; color: var(--muted); font-size: 12px; line-height: 1.45;
|
||||
}
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.list-item {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 12px 14px; text-align: left; width: 100%;
|
||||
}
|
||||
.list-item .lead {
|
||||
width: 42px; height: 42px; border-radius: 12px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--card2); font-size: 22px;
|
||||
}
|
||||
.list-item .body { flex: 1; min-width: 0; }
|
||||
.list-item .title { font-size: 14px; font-weight: 650; }
|
||||
.list-item .meta { margin-top: 4px; }
|
||||
.fav-dot { color: var(--accent); font-size: 12px; margin-left: 6px; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.chip-btn {
|
||||
padding: 7px 12px; border-radius: 999px; font-size: 12px;
|
||||
background: var(--card); border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.chip-btn.is-on { color: #1a1520; background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.detail-hero {
|
||||
border-radius: 20px; padding: 18px; margin-bottom: 14px;
|
||||
background: linear-gradient(160deg, rgba(240,198,116,.18), transparent 55%), var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.detail-hero .emoji { font-size: 36px; }
|
||||
.detail-hero h1 { font-size: 24px; margin: 8px 0 4px; }
|
||||
.kv {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 12px 0;
|
||||
}
|
||||
.kv .cell {
|
||||
background: var(--card); border-radius: 14px; padding: 12px; border: 1px solid var(--border);
|
||||
}
|
||||
.kv .k { color: var(--muted); font-size: 11px; }
|
||||
.kv .v { display: block; margin-top: 4px; font-size: 16px; font-weight: 700; color: var(--accent); }
|
||||
|
||||
.hl { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 14px; }
|
||||
.hl span {
|
||||
font-size: 12px; padding: 6px 10px; border-radius: 999px;
|
||||
background: rgba(125, 211, 199, 0.1); color: #b7efe6;
|
||||
}
|
||||
|
||||
.article {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 16px; line-height: 1.7; font-size: 14px; color: #e8e2f4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.article h2, .article h3 { color: var(--text); margin: 14px 0 8px; font-size: 16px; }
|
||||
.article p { margin: 0 0 10px; }
|
||||
.article ul, .article ol { padding-left: 18px; margin-bottom: 10px; }
|
||||
.article li { margin: 4px 0; }
|
||||
.article table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 10px 0; }
|
||||
.article th, .article td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; }
|
||||
|
||||
.form {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 14px;
|
||||
}
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%; padding: 10px 12px; border-radius: 12px;
|
||||
background: rgba(0,0,0,.25); border: 1px solid var(--border);
|
||||
}
|
||||
.check-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.check-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
|
||||
padding: 12px 14px; text-align: left; width: 100%;
|
||||
}
|
||||
.check-item.is-on { border-color: rgba(240,198,116,.5); background: rgba(240,198,116,.08); }
|
||||
.check-box {
|
||||
width: 20px; height: 20px; border-radius: 6px; border: 1.5px solid var(--muted);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 12px;
|
||||
}
|
||||
.check-item.is-on .check-box { background: var(--accent); border-color: var(--accent); color: #1a1520; }
|
||||
|
||||
.lifestyle {
|
||||
display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.life-card {
|
||||
min-width: 140px; flex-shrink: 0;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 12px; text-align: left;
|
||||
}
|
||||
.life-card.is-on { border-color: var(--accent); }
|
||||
.life-detail {
|
||||
margin-top: 10px; padding: 12px; border-radius: 14px;
|
||||
background: rgba(240,198,116,.08); color: #e8dfc8; font-size: 13px; line-height: 1.55;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 10px 0 14px; padding: 10px 12px; border-radius: 12px;
|
||||
background: rgba(240,198,116,.1); color: #e8d7a8; font-size: 12px; line-height: 1.5;
|
||||
}
|
||||
.empty { text-align: center; color: var(--muted); padding: 28px 10px; font-size: 13px; }
|
||||
|
||||
.map-board {
|
||||
position: relative; height: 220px; border-radius: 18px; overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 20% 40%, rgba(125,211,199,.15), transparent 30%),
|
||||
radial-gradient(circle at 70% 35%, rgba(240,198,116,.12), transparent 28%),
|
||||
linear-gradient(180deg, #17141f, #0f0d15);
|
||||
border: 1px solid var(--border); margin-bottom: 12px;
|
||||
}
|
||||
.map-pin {
|
||||
position: absolute; transform: translate(-50%, -100%);
|
||||
font-size: 18px; filter: drop-shadow(0 2px 4px rgba(0,0,0,.5));
|
||||
}
|
||||
.map-pin span {
|
||||
display: block; margin-top: 2px; font-size: 9px; color: var(--text);
|
||||
background: rgba(0,0,0,.45); padding: 1px 4px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.sheet { position: fixed; inset: 0; z-index: 60; }
|
||||
.sheet-mask { position: absolute; inset: 0; background: rgba(0,0,0,.55); }
|
||||
.sheet-panel {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
max-height: 78%; overflow: auto;
|
||||
background: #16131e; border-radius: 20px 20px 0 0;
|
||||
padding: 10px 16px calc(20px + var(--safe-b));
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sheet-handle {
|
||||
width: 42px; height: 4px; border-radius: 99px; background: rgba(255,255,255,.2);
|
||||
margin: 4px auto 14px;
|
||||
}
|
||||
|
||||
.compare-pick { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.compare-pick select { flex: 1; padding: 10px; border-radius: 12px; background: rgba(0,0,0,.25); border: 1px solid var(--border); }
|
||||
.compare-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.compare-table th, .compare-table td { border-bottom: 1px solid var(--border); padding: 10px 6px; text-align: left; }
|
||||
.compare-table th { color: var(--muted); font-weight: 500; }
|
||||
|
||||
.quote {
|
||||
background: var(--card); border-left: 3px solid var(--accent);
|
||||
border-radius: 0 14px 14px 0; padding: 12px 14px; margin-bottom: 10px;
|
||||
}
|
||||
.quote p { font-size: 13px; line-height: 1.55; }
|
||||
.quote .who { margin-top: 8px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
@ -1,40 +1,639 @@
|
||||
/**
|
||||
* Official XHS miniTool bridge helpers (Chrome 61 / ES2017).
|
||||
* Prefer window.xhs.miniTool Storage (9.46+); fall back to localStorage.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
var STORAGE_MIN = 9460;
|
||||
var COMMENT_MIN = 9490;
|
||||
var FILE_MIN = 9490;
|
||||
|
||||
function readBuildVersion(launchOptions) {
|
||||
var env = launchOptions && launchOptions.miniToolEnv;
|
||||
return Number(env && env.buildVersion) || 0;
|
||||
}
|
||||
|
||||
function getClientVersion(buildVersion) {
|
||||
return Math.floor((Number(buildVersion) || 0) / 1000);
|
||||
}
|
||||
|
||||
function isClientVersionAtLeast(buildVersion, minimum) {
|
||||
return getClientVersion(buildVersion) >= minimum;
|
||||
}
|
||||
|
||||
function getMiniTool() {
|
||||
return global.xhs && global.xhs.miniTool ? global.xhs.miniTool : null;
|
||||
}
|
||||
function setLocalData(key, data) {
|
||||
var serialized = JSON.stringify(data);
|
||||
|
||||
function getBuildVersion() {
|
||||
var xhs = global.xhs;
|
||||
var sync = readBuildVersion(xhs && xhs.launchOptions);
|
||||
if (sync) {
|
||||
return Promise.resolve(sync);
|
||||
}
|
||||
var mt = getMiniTool();
|
||||
if (mt && typeof mt.setStorage === 'function') {
|
||||
return Promise.resolve(mt.setStorage({ key: key, data: serialized }))
|
||||
.then(function () { return true; })
|
||||
.catch(function () {
|
||||
try { localStorage.setItem(key, serialized); return true; } catch (e) { return false; }
|
||||
});
|
||||
if (!mt || typeof mt.getLaunchOptions !== "function") {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
try { localStorage.setItem(key, serialized); return Promise.resolve(true); } catch (e) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
function getLocalData(key) {
|
||||
var mt = getMiniTool();
|
||||
if (mt && typeof mt.getStorage === 'function') {
|
||||
return Promise.resolve(mt.getStorage({ key: key }))
|
||||
.then(function (res) {
|
||||
var raw = res && (res.data || res);
|
||||
if (typeof raw === 'string') {
|
||||
try { return JSON.parse(raw); } catch (e) { return null; }
|
||||
}
|
||||
return raw || null;
|
||||
return Promise.resolve(mt.getLaunchOptions())
|
||||
.then(function (opts) {
|
||||
return readBuildVersion(opts);
|
||||
})
|
||||
.catch(function () {
|
||||
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; }
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
try { return Promise.resolve(JSON.parse(localStorage.getItem(key) || 'null')); } catch (e) {
|
||||
|
||||
function setLocalData(key, data, encrypt) {
|
||||
var serialized;
|
||||
try {
|
||||
serialized = JSON.stringify(data);
|
||||
} catch (e) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (typeof serialized !== "string") return Promise.resolve(false);
|
||||
var useEncrypt = !!encrypt;
|
||||
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
isClientVersionAtLeast(buildVersion, STORAGE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.setStorage === "function"
|
||||
) {
|
||||
var payload = { key: key, data: serialized };
|
||||
if (useEncrypt) payload.encrypt = true;
|
||||
return Promise.resolve(mt.setStorage(payload))
|
||||
.then(function () {
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return writeBrowser(key, serialized);
|
||||
});
|
||||
}
|
||||
return writeBrowser(key, serialized);
|
||||
});
|
||||
}
|
||||
|
||||
function writeBrowser(key, serialized) {
|
||||
try {
|
||||
global.localStorage.setItem(key, serialized);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalData(key, fallback, encrypt) {
|
||||
var useEncrypt = !!encrypt;
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
isClientVersionAtLeast(buildVersion, STORAGE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.getStorage === "function"
|
||||
) {
|
||||
var payload = { key: key };
|
||||
if (useEncrypt) payload.encrypt = true;
|
||||
return Promise.resolve(mt.getStorage(payload))
|
||||
.then(function (res) {
|
||||
var raw = res && res.data;
|
||||
if (raw != null && raw !== "") {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
// Retry without encrypt (migration), then browser
|
||||
if (useEncrypt) {
|
||||
return Promise.resolve(mt.getStorage({ key: key }))
|
||||
.then(function (res2) {
|
||||
var raw2 = res2 && res2.data;
|
||||
if (raw2 != null && raw2 !== "") {
|
||||
try {
|
||||
var parsed = JSON.parse(raw2);
|
||||
return setLocalData(key, parsed, true).then(function () {
|
||||
return parsed;
|
||||
});
|
||||
} catch (e2) {
|
||||
return readBrowser(key, fallback);
|
||||
}
|
||||
}
|
||||
return readBrowser(key, fallback);
|
||||
})
|
||||
.catch(function () {
|
||||
return readBrowser(key, fallback);
|
||||
});
|
||||
}
|
||||
var fromBrowser = readBrowser(key, null);
|
||||
if (fromBrowser == null) return fallback;
|
||||
return writeNative(mt, key, JSON.stringify(fromBrowser), false).then(
|
||||
function () {
|
||||
return fromBrowser;
|
||||
},
|
||||
function () {
|
||||
return fromBrowser;
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(function () {
|
||||
return readBrowser(key, fallback);
|
||||
});
|
||||
}
|
||||
return readBrowser(key, fallback);
|
||||
});
|
||||
}
|
||||
|
||||
function writeNative(mt, key, serialized, encrypt) {
|
||||
if (!mt || typeof mt.setStorage !== "function") {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
var payload = { key: key, data: serialized };
|
||||
if (encrypt) payload.encrypt = true;
|
||||
return Promise.resolve(mt.setStorage(payload)).then(
|
||||
function () {
|
||||
return true;
|
||||
},
|
||||
function () {
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readBrowser(key, fallback) {
|
||||
try {
|
||||
var raw = global.localStorage.getItem(key);
|
||||
if (raw == null || raw === "") return fallback;
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function removeLocalData(key) {
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
isClientVersionAtLeast(buildVersion, STORAGE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.removeStorage === "function"
|
||||
) {
|
||||
return Promise.resolve(mt.removeStorage({ key: key }))
|
||||
.then(function () {
|
||||
removeBrowser(key);
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return removeBrowser(key);
|
||||
});
|
||||
}
|
||||
return removeBrowser(key);
|
||||
});
|
||||
}
|
||||
|
||||
function clearLocalData() {
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
isClientVersionAtLeast(buildVersion, STORAGE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.clearStorage === "function"
|
||||
) {
|
||||
return Promise.resolve(mt.clearStorage())
|
||||
.then(function () {
|
||||
clearBrowser();
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return clearBrowser();
|
||||
});
|
||||
}
|
||||
return clearBrowser();
|
||||
});
|
||||
}
|
||||
|
||||
function removeBrowser(key) {
|
||||
try {
|
||||
global.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearBrowser() {
|
||||
try {
|
||||
global.localStorage.clear();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getLaunchOptions() {
|
||||
var xhs = global.xhs;
|
||||
if (xhs && xhs.launchOptions) {
|
||||
return Promise.resolve(xhs.launchOptions);
|
||||
}
|
||||
var mt = getMiniTool();
|
||||
if (!mt || typeof mt.getLaunchOptions !== "function") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(mt.getLaunchOptions()).catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
global.NomadroXhs = { setLocalData: setLocalData, getLocalData: getLocalData };
|
||||
|
||||
/** Read miniToolSnapshotInfo from launch options (comment re-entry, §3.9). */
|
||||
function readSnapshotInfo(launchOptions) {
|
||||
if (!launchOptions) return null;
|
||||
var raw =
|
||||
launchOptions.miniToolSnapshotInfo ||
|
||||
(launchOptions.query && launchOptions.query.miniToolSnapshotInfo) ||
|
||||
(launchOptions.referrerInfo &&
|
||||
launchOptions.referrerInfo.miniToolSnapshotInfo) ||
|
||||
(launchOptions.extraData && launchOptions.extraData.miniToolSnapshotInfo);
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "object") return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function callApi(name, options) {
|
||||
var mt = getMiniTool();
|
||||
if (!mt || typeof mt[name] !== "function") {
|
||||
return Promise.reject({ errMsg: name + ":fail not available" });
|
||||
}
|
||||
return Promise.resolve(mt[name](options || {}));
|
||||
}
|
||||
|
||||
function writeTempFile(dataUrl) {
|
||||
return callApi("writeTempFile", { data: dataUrl }).then(function (res) {
|
||||
return (res && res.filePath) || dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function saveImageToPhotosAlbum(filePath) {
|
||||
return callApi("saveImageToPhotosAlbum", { filePath: filePath });
|
||||
}
|
||||
|
||||
function postNote(opts) {
|
||||
return callApi("postNote", opts);
|
||||
}
|
||||
|
||||
function canPostComment(buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
return (
|
||||
isClientVersionAtLeast(buildVersion, COMMENT_MIN) &&
|
||||
mt &&
|
||||
typeof mt.interactionOpenApi === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function postComment(payload, saveToAlbum) {
|
||||
return callApi("interactionOpenApi", {
|
||||
payload: payload,
|
||||
saveToAlbum: saveToAlbum !== false
|
||||
});
|
||||
}
|
||||
|
||||
function getStorageInfo() {
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!isClientVersionAtLeast(buildVersion, STORAGE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.getStorageInfo !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Promise.resolve(mt.getStorageInfo()).catch(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function canUseFileSystem(buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
return (
|
||||
isClientVersionAtLeast(buildVersion, FILE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.writeFile === "function" &&
|
||||
typeof mt.readFile === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function getUserDataPath() {
|
||||
return getLaunchOptions().then(function (opts) {
|
||||
var env = opts && opts.miniToolEnv;
|
||||
return (env && env.userDataPath) || "";
|
||||
});
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
var mt = getMiniTool();
|
||||
if (!mt || typeof mt.mkdir !== "function") {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(mt.mkdir({ dirPath: dirPath, recursive: true }))
|
||||
.then(function () {
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function writeUtf8File(relativePath, data) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
if (!canUseFileSystem(bv)) {
|
||||
return Promise.reject({ errMsg: "writeFile:fail version" });
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return Promise.reject({ errMsg: "writeFile:fail no path" });
|
||||
var rel = String(relativePath || "").replace(/^\/+/, "");
|
||||
var parts = rel.split("/");
|
||||
var dir =
|
||||
parts.length > 1 ? root + "/" + parts.slice(0, -1).join("/") : root;
|
||||
var filePath = root + "/" + rel;
|
||||
return ensureDir(dir).then(function () {
|
||||
return callApi("writeFile", {
|
||||
filePath: filePath,
|
||||
data: typeof data === "string" ? data : JSON.stringify(data),
|
||||
encoding: "utf8"
|
||||
}).then(function () {
|
||||
return filePath;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function appendUtf8File(relativePath, data) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
if (!canUseFileSystem(bv)) {
|
||||
return Promise.reject({ errMsg: "appendFile:fail version" });
|
||||
}
|
||||
var mt = getMiniTool();
|
||||
if (!mt || typeof mt.appendFile !== "function") {
|
||||
// Fallback: read + write
|
||||
return readUtf8File(relativePath).then(function (prev) {
|
||||
return writeUtf8File(
|
||||
relativePath,
|
||||
(prev || "") + (typeof data === "string" ? data : String(data))
|
||||
);
|
||||
});
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return Promise.reject({ errMsg: "appendFile:fail no path" });
|
||||
var rel = String(relativePath || "").replace(/^\/+/, "");
|
||||
var parts = rel.split("/");
|
||||
var dir =
|
||||
parts.length > 1 ? root + "/" + parts.slice(0, -1).join("/") : root;
|
||||
var filePath = root + "/" + rel;
|
||||
return ensureDir(dir).then(function () {
|
||||
return callApi("appendFile", {
|
||||
filePath: filePath,
|
||||
data: typeof data === "string" ? data : String(data),
|
||||
encoding: "utf8"
|
||||
}).then(function () {
|
||||
return filePath;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function postVideoNote(opts) {
|
||||
var videoRes = { video_url: opts.videoPath };
|
||||
if (opts.coverPath) videoRes.cover_url = opts.coverPath;
|
||||
return callApi("postNote", {
|
||||
title: opts.title,
|
||||
content: opts.content,
|
||||
pageType: "video_publish",
|
||||
mediaInfo: {
|
||||
video_resources: videoRes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readUtf8File(relativePath) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
if (!canUseFileSystem(bv)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return null;
|
||||
var filePath = root + "/" + String(relativePath || "").replace(/^\/+/, "");
|
||||
return callApi("readFile", { filePath: filePath, encoding: "utf8" })
|
||||
.then(function (res) {
|
||||
return res && res.data != null ? res.data : null;
|
||||
})
|
||||
.catch(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Write dataURL to temp, then optionally persist via saveFile (9.49+). */
|
||||
function persistImage(dataUrl, relativePath) {
|
||||
return writeTempFile(dataUrl).then(function (tempPath) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!relativePath ||
|
||||
!isClientVersionAtLeast(bv, FILE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.saveFile !== "function"
|
||||
) {
|
||||
return tempPath;
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return tempPath;
|
||||
var dest = root + "/" + String(relativePath).replace(/^\/+/, "");
|
||||
var dir = dest.replace(/\/[^/]+$/, "");
|
||||
return ensureDir(dir).then(function () {
|
||||
return callApi("saveFile", {
|
||||
tempFilePath: tempPath,
|
||||
filePath: dest
|
||||
})
|
||||
.then(function (res) {
|
||||
return (res && res.savedFilePath) || dest || tempPath;
|
||||
})
|
||||
.catch(function () {
|
||||
return tempPath;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFileStorageInfo() {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!isClientVersionAtLeast(bv, FILE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.getFileStorageInfo !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Promise.resolve(mt.getFileStorageInfo()).catch(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unlinkFile(relativeOrAbsPath) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!isClientVersionAtLeast(bv, FILE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.unlink !== "function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
var path = String(relativeOrAbsPath || "");
|
||||
if (!path) return false;
|
||||
var p = Promise.resolve(path);
|
||||
if (path.indexOf("/") !== 0 && path.indexOf("usr") !== 0) {
|
||||
p = getUserDataPath().then(function (root) {
|
||||
return root ? root + "/" + path.replace(/^\/+/, "") : path;
|
||||
});
|
||||
}
|
||||
return p.then(function (filePath) {
|
||||
return callApi("unlink", { filePath: filePath })
|
||||
.then(function () {
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readDirList(relativeDir) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!isClientVersionAtLeast(bv, FILE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.readDir !== "function"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return [];
|
||||
var dirPath =
|
||||
root +
|
||||
(relativeDir
|
||||
? "/" + String(relativeDir).replace(/^\/+|\/+$/g, "")
|
||||
: "");
|
||||
return callApi("readDir", { dirPath: dirPath })
|
||||
.then(function (res) {
|
||||
var files = (res && res.files) || [];
|
||||
return files.map(function (f) {
|
||||
if (typeof f === "string") {
|
||||
return { name: f, path: dirPath + "/" + f };
|
||||
}
|
||||
var name = f.name || f.fileName || String(f);
|
||||
return {
|
||||
name: name,
|
||||
path: f.path || f.filePath || dirPath + "/" + name,
|
||||
isDir: !!(f.isDir || f.isDirectory)
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
return [];
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupShareDir() {
|
||||
return readDirList("share").then(function (files) {
|
||||
var chain = Promise.resolve(0);
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
(function (file) {
|
||||
if (file.isDir) return;
|
||||
chain = chain.then(function (n) {
|
||||
return callApi("unlink", { filePath: file.path })
|
||||
.then(function () {
|
||||
return n + 1;
|
||||
})
|
||||
.catch(function () {
|
||||
return n;
|
||||
});
|
||||
});
|
||||
})(files[i]);
|
||||
}
|
||||
return chain;
|
||||
});
|
||||
}
|
||||
|
||||
/** Convert <input type=file> File / Blob to data URL (offline, no network). */
|
||||
function blobToDataUrl(blob) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (!blob) {
|
||||
reject({ errMsg: "blobToDataUrl:fail empty" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = function () {
|
||||
reject({ errMsg: "blobToDataUrl:fail read" });
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (e) {
|
||||
reject({ errMsg: "blobToDataUrl:fail " + (e && e.message) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.XhsBridge = {
|
||||
getBuildVersion: getBuildVersion,
|
||||
getClientVersion: getClientVersion,
|
||||
isClientVersionAtLeast: isClientVersionAtLeast,
|
||||
setLocalData: setLocalData,
|
||||
getLocalData: getLocalData,
|
||||
removeLocalData: removeLocalData,
|
||||
clearLocalData: clearLocalData,
|
||||
getStorageInfo: getStorageInfo,
|
||||
getFileStorageInfo: getFileStorageInfo,
|
||||
getLaunchOptions: getLaunchOptions,
|
||||
readSnapshotInfo: readSnapshotInfo,
|
||||
writeTempFile: writeTempFile,
|
||||
saveImageToPhotosAlbum: saveImageToPhotosAlbum,
|
||||
postNote: postNote,
|
||||
canPostComment: canPostComment,
|
||||
postComment: postComment,
|
||||
canUseFileSystem: canUseFileSystem,
|
||||
getUserDataPath: getUserDataPath,
|
||||
writeUtf8File: writeUtf8File,
|
||||
appendUtf8File: appendUtf8File,
|
||||
readUtf8File: readUtf8File,
|
||||
persistImage: persistImage,
|
||||
unlinkFile: unlinkFile,
|
||||
readDirList: readDirList,
|
||||
cleanupShareDir: cleanupShareDir,
|
||||
blobToDataUrl: blobToDataUrl,
|
||||
postVideoNote: postVideoNote,
|
||||
getMiniTool: getMiniTool,
|
||||
STORAGE_MIN: STORAGE_MIN,
|
||||
COMMENT_MIN: COMMENT_MIN,
|
||||
FILE_MIN: FILE_MIN
|
||||
};
|
||||
})(window);
|
||||
|
||||
103
redmini/scripts/build_data.py
Normal file
103
redmini/scripts/build_data.py
Normal file
@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild redmini/src/data.js from backend mock data (offline snapshot)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND = ROOT / "backend"
|
||||
OUT = ROOT / "redmini" / "src" / "data.js"
|
||||
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
|
||||
from app.data import community_data as c # noqa: E402
|
||||
from app.data import digital_content as d # noqa: E402
|
||||
from app.data import mock_data as m # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
meetups = [
|
||||
{k: v for k, v in x.items() if k not in ("mirotalkRoom", "loungeChannel", "meetingUrl")}
|
||||
for x in c.MEETUPS
|
||||
]
|
||||
details = getattr(c, "DISCUSSION_DETAILS", {}) or {}
|
||||
payload = {
|
||||
"brand": "nomadro",
|
||||
"tagline": "用一行代码环游世界",
|
||||
"offlineNote": "小红书小程序无法联网。本包为官网内容离线镜像,收藏/计划仅保存在本机。完整互动请打开 nomadweb.nomadro.com",
|
||||
"site": "https://nomadweb.nomadro.com",
|
||||
"stats": {
|
||||
"destinations": len(m.DESTINATIONS),
|
||||
"visas": len(m.VISAS),
|
||||
"meetups": len(meetups),
|
||||
"blog": len(m.BLOG_POSTS),
|
||||
"faqs": len(m.FAQS),
|
||||
},
|
||||
"ticker": [
|
||||
"🌏 全球数字游民正在路上",
|
||||
"📋 签证政策实时整理(离线快照)",
|
||||
"🎉 同城活动与社区讨论可先浏览",
|
||||
"🎓 游民学院免费课时可学",
|
||||
],
|
||||
"rings": {
|
||||
"explore": [
|
||||
{"id": "destinations", "emoji": "🌴", "title": "目的地", "desc": "发现下一座城市"},
|
||||
{"id": "visas", "emoji": "📋", "title": "签证指南", "desc": "远程友好签证"},
|
||||
{"id": "matcher", "emoji": "🎯", "title": "智能匹配", "desc": "按预算气候排序"},
|
||||
{"id": "compare", "emoji": "⚖️", "title": "城市对比", "desc": "并排看成本网速"},
|
||||
],
|
||||
"connect": [
|
||||
{"id": "meetups", "emoji": "🎉", "title": "游民活动", "desc": "同城与线上"},
|
||||
{"id": "community", "emoji": "💬", "title": "社区讨论", "desc": "签证住宿经验"},
|
||||
{"id": "voices", "emoji": "🗣️", "title": "游民心声", "desc": "真实旅居评价"},
|
||||
],
|
||||
"grow": [
|
||||
{"id": "digital", "emoji": "🎓", "title": "游民学院", "desc": "远程工作课程"},
|
||||
{"id": "blog", "emoji": "📝", "title": "博客", "desc": "指南与攻略"},
|
||||
{"id": "tools", "emoji": "🧰", "title": "工具箱", "desc": "费用估算等"},
|
||||
{"id": "faq", "emoji": "❓", "title": "常见问题", "desc": "启动资金与保险"},
|
||||
],
|
||||
},
|
||||
"lifestyle": [
|
||||
{"key": "morning", "emoji": "🌅", "title": "清晨", "body": "咖啡馆开机,时区对齐,列出今日三件交付。"},
|
||||
{"key": "work", "emoji": "💻", "title": "深度工作", "body": "联合办公或家中安静角落,异步协作优先。"},
|
||||
{"key": "explore", "emoji": "🚶", "title": "探索城市", "body": "傍晚散步、夜市与同城 meetup。"},
|
||||
{"key": "collab", "emoji": "🤝", "title": "协作", "body": "和同行交换签证与住宿情报。"},
|
||||
{"key": "social", "emoji": "🌙", "title": "社交", "body": "社区圆桌、兴趣局,或安静充电。"},
|
||||
],
|
||||
"planChecklist": [
|
||||
{"id": "visa", "label": "确认签证/停留天数"},
|
||||
{"id": "flight", "label": "预订机票"},
|
||||
{"id": "housing", "label": "首周短租/酒店"},
|
||||
{"id": "sim", "label": "当地 SIM / eSIM"},
|
||||
{"id": "cowork", "label": "踩点联合办公"},
|
||||
{"id": "insurance", "label": "国际医疗保险"},
|
||||
{"id": "budget", "label": "写下月预算"},
|
||||
{"id": "meetup", "label": "报名一场同城活动"},
|
||||
],
|
||||
"destinations": m.DESTINATIONS,
|
||||
"visas": m.VISAS,
|
||||
"faqs": m.FAQS,
|
||||
"testimonials": m.TESTIMONIALS,
|
||||
"tools": m.TOOLS,
|
||||
"blog": m.BLOG_POSTS,
|
||||
"blogContent": m.BLOG_CONTENT,
|
||||
"meetups": meetups,
|
||||
"discussions": c.DISCUSSIONS,
|
||||
"discussionDetails": details if isinstance(details, dict) else {},
|
||||
"course": d.COURSE_MODULES,
|
||||
"lessons": d.LESSONS,
|
||||
"jobs": (d.JOBS[:8] if getattr(d, "JOBS", None) else []),
|
||||
}
|
||||
OUT.write_text(
|
||||
"window.NOMADRO_DATA = " + json.dumps(payload, ensure_ascii=False, default=str) + ";\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"wrote {OUT} ({OUT.stat().st_size} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,21 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail if release/ contains networking APIs (XHS miniTool rule)."""
|
||||
"""Fail if release/ contains networking APIs (XHS miniTool cannot network)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE = ROOT / "release"
|
||||
# Ban callable network surfaces only — plain URL text in copy is OK.
|
||||
BANNED = re.compile(
|
||||
r"\b(fetch|XMLHttpRequest|WebSocket|navigator\.sendBeacon)\b|"
|
||||
r"https?://(?!miniapp\.xiaohongshu\.com)",
|
||||
r"\b(fetch\s*\(|XMLHttpRequest|WebSocket\s*\(|navigator\.sendBeacon|"
|
||||
r"axios\.|\$\.ajax|\$\.get\s*\(|\$\.post\s*\()",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
bad = []
|
||||
bad: list[str] = []
|
||||
for p in RELEASE.rglob("*"):
|
||||
if not p.is_file() or p.suffix.lower() not in {".js", ".html", ".css"}:
|
||||
continue
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,42 +1,805 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
var data = window.NOMADRO_DATA || { destinations: [], tips: [] };
|
||||
var root = document.getElementById('app');
|
||||
var favKey = 'nomadro_redmini_favs';
|
||||
"use strict";
|
||||
|
||||
function render(favs) {
|
||||
favs = favs || [];
|
||||
var cards = (data.destinations || []).map(function (d) {
|
||||
var marked = favs.indexOf(d.slug) >= 0 ? ' · 已收藏' : '';
|
||||
return (
|
||||
'<article class="card" data-slug="' + d.slug + '">' +
|
||||
'<div class="name">' + d.name + '</div>' +
|
||||
'<div class="meta">' + (d.country || '') + ' · ¥' + (d.cost || '—') + '/月 · ★' + (d.rating || '—') + '</div>' +
|
||||
'<div class="meta">' + (d.tagline || '') + '</div>' +
|
||||
'<div class="fav">' + (marked || '点按收藏(仅本地)') + '</div>' +
|
||||
'</article>'
|
||||
);
|
||||
}).join('');
|
||||
var tips = (data.tips || []).map(function (t) { return '<li>' + t + '</li>'; }).join('');
|
||||
root.innerHTML =
|
||||
'<p class="brand">' + (data.brand || 'nomadro') + '</p>' +
|
||||
'<p class="tag">' + (data.tagline || '') + '</p>' +
|
||||
'<p class="note">' + (data.note || '') + '</p>' +
|
||||
cards +
|
||||
'<section class="tips"><h2>离线提示</h2><ul>' + tips + '</ul></section>';
|
||||
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";
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll('.card'), function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
var slug = el.getAttribute('data-slug');
|
||||
var next = favs.slice();
|
||||
var i = next.indexOf(slug);
|
||||
if (i >= 0) next.splice(i, 1); else next.push(slug);
|
||||
window.NomadroXhs.setLocalData(favKey, next).then(function () { render(next); });
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
window.NomadroXhs.getLocalData(favKey).then(function (favs) {
|
||||
render(Array.isArray(favs) ? favs : []);
|
||||
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();
|
||||
})();
|
||||
|
||||
1
redmini/tool/data.js
Normal file
1
redmini/tool/data.js
Normal file
File diff suppressed because one or more lines are too long
@ -3,11 +3,41 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>nomadro · 离线目的地</title>
|
||||
<title>nomadro · 数字游民</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="app">
|
||||
<header class="top-chrome" id="top-chrome">
|
||||
<div class="top-left">
|
||||
<button type="button" class="icon-btn is-hidden" id="btn-back" aria-label="返回">←</button>
|
||||
<div>
|
||||
<p class="top-brand" id="top-brand">nomadro</p>
|
||||
<p class="top-sub" id="top-sub">离线镜像</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="chip" id="btn-offline" title="离线说明">离线</button>
|
||||
</header>
|
||||
|
||||
<main class="main" id="main"></main>
|
||||
|
||||
<nav class="tabbar" id="tabbar" aria-label="主导航">
|
||||
<button type="button" class="tab is-active" data-tab="home"><span>🏠</span><i>首页</i></button>
|
||||
<button type="button" class="tab" data-tab="explore"><span>🧭</span><i>探索</i></button>
|
||||
<button type="button" class="tab" data-tab="connect"><span>🤝</span><i>连接</i></button>
|
||||
<button type="button" class="tab" data-tab="grow"><span>🌱</span><i>成长</i></button>
|
||||
<button type="button" class="tab" data-tab="mine"><span>👤</span><i>我的</i></button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sheet is-hidden" id="sheet" role="dialog" aria-modal="true">
|
||||
<div class="sheet-mask" id="sheet-mask"></div>
|
||||
<div class="sheet-panel">
|
||||
<div class="sheet-handle"></div>
|
||||
<div id="sheet-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./data.js"></script>
|
||||
<script src="./xhs-bridge.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
|
||||
@ -1,12 +1,277 @@
|
||||
*{box-sizing:border-box}html,body{margin:0;padding:0;background:#0c0b12;color:#f4f0ea;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
|
||||
#app{min-height:100vh;padding:28px 20px 48px}
|
||||
.brand{font-size:28px;font-weight:700;letter-spacing:1px}
|
||||
.tag{margin:8px 0 6px;color:#a8a3b3;font-size:14px}
|
||||
.note{color:#c9a227;font-size:12px;margin-bottom:20px}
|
||||
.card{background:rgba(255,255,255,.06);border-radius:14px;padding:14px;margin-bottom:12px}
|
||||
.name{font-size:17px;font-weight:600}
|
||||
.meta{margin-top:6px;color:#a8a3b3;font-size:13px}
|
||||
.tips{margin-top:24px}
|
||||
.tips h2{font-size:16px;margin:0 0 10px}
|
||||
.tips li{margin:8px 0;color:#cfc9d8;font-size:13px;line-height:1.5}
|
||||
.fav{margin-top:10px;font-size:12px;color:#f0c674}
|
||||
:root {
|
||||
--bg: #0b0a10;
|
||||
--bg2: #14121c;
|
||||
--card: rgba(255, 255, 255, 0.06);
|
||||
--card2: rgba(255, 255, 255, 0.09);
|
||||
--text: #f4f0ea;
|
||||
--muted: #9a94a8;
|
||||
--accent: #f0c674;
|
||||
--accent2: #7dd3c7;
|
||||
--danger: #f07178;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--safe-b: env(safe-area-inset-bottom, 0px);
|
||||
--safe-t: env(safe-area-inset-top, 0px);
|
||||
--tab-h: 64px;
|
||||
--top-h: 56px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { border: 0; background: none; cursor: pointer; }
|
||||
.is-hidden { display: none !important; }
|
||||
|
||||
#app {
|
||||
min-height: 100%;
|
||||
min-height: 100vh;
|
||||
padding-top: calc(var(--top-h) + var(--safe-t));
|
||||
padding-bottom: calc(var(--tab-h) + var(--safe-b) + 8px);
|
||||
}
|
||||
|
||||
.top-chrome {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
|
||||
height: calc(var(--top-h) + var(--safe-t));
|
||||
padding: var(--safe-t) 14px 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: rgba(11, 10, 16, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.top-left { display: flex; align-items: center; gap: 8px; }
|
||||
.top-brand { font-weight: 700; font-size: 16px; letter-spacing: 0.4px; }
|
||||
.top-sub { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.icon-btn {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
background: var(--card); color: var(--text); font-size: 16px;
|
||||
}
|
||||
.chip {
|
||||
padding: 6px 10px; border-radius: 999px; font-size: 12px;
|
||||
background: rgba(240, 198, 116, 0.15); color: var(--accent);
|
||||
border: 1px solid rgba(240, 198, 116, 0.35);
|
||||
}
|
||||
|
||||
.main { padding: 12px 14px 20px; max-width: 720px; margin: 0 auto; }
|
||||
|
||||
.tabbar {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
|
||||
height: calc(var(--tab-h) + var(--safe-b));
|
||||
padding: 6px 6px var(--safe-b);
|
||||
display: flex; background: rgba(16, 14, 24, 0.96);
|
||||
border-top: 1px solid var(--border);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.tab {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
color: var(--muted); font-size: 10px; padding: 6px 0;
|
||||
}
|
||||
.tab span { font-size: 18px; line-height: 1; }
|
||||
.tab.is-active { color: var(--accent); }
|
||||
|
||||
.hero {
|
||||
position: relative; overflow: hidden; border-radius: 22px;
|
||||
padding: 22px 18px 20px;
|
||||
background:
|
||||
radial-gradient(500px 220px at 0% 0%, rgba(240, 198, 116, 0.22), transparent 55%),
|
||||
radial-gradient(420px 200px at 100% 0%, rgba(125, 211, 199, 0.16), transparent 50%),
|
||||
linear-gradient(165deg, #15121d, #0e0c14 70%);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-block; font-size: 11px; color: var(--accent2);
|
||||
background: rgba(125, 211, 199, 0.12); padding: 4px 8px; border-radius: 999px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hero h1 { font-size: 28px; line-height: 1.2; font-weight: 760; margin-bottom: 8px; }
|
||||
.hero p { color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.hero-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 10px 14px; border-radius: 999px; font-size: 13px; font-weight: 600;
|
||||
background: var(--accent); color: #1a1520;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent; color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn.sm { padding: 7px 11px; font-size: 12px; }
|
||||
|
||||
.section { margin: 18px 0 8px; }
|
||||
.section-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.section-head h2 { font-size: 16px; font-weight: 700; }
|
||||
.section-head .more { font-size: 12px; color: var(--accent); }
|
||||
|
||||
.stats {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 14px 0;
|
||||
}
|
||||
.stat {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
|
||||
padding: 10px 6px; text-align: center;
|
||||
}
|
||||
.stat b { display: block; color: var(--accent); font-size: 16px; }
|
||||
.stat span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
|
||||
.ring-grid, .card-grid { display: grid; gap: 10px; }
|
||||
.ring-grid { grid-template-columns: 1fr 1fr; }
|
||||
.ring-card, .card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 14px; text-align: left;
|
||||
}
|
||||
.ring-card .emoji, .card .emoji { font-size: 22px; display: block; margin-bottom: 8px; }
|
||||
.ring-card strong, .card strong { display: block; font-size: 14px; margin-bottom: 4px; }
|
||||
.ring-card span, .card .desc, .meta {
|
||||
display: block; color: var(--muted); font-size: 12px; line-height: 1.45;
|
||||
}
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.list-item {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 12px 14px; text-align: left; width: 100%;
|
||||
}
|
||||
.list-item .lead {
|
||||
width: 42px; height: 42px; border-radius: 12px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--card2); font-size: 22px;
|
||||
}
|
||||
.list-item .body { flex: 1; min-width: 0; }
|
||||
.list-item .title { font-size: 14px; font-weight: 650; }
|
||||
.list-item .meta { margin-top: 4px; }
|
||||
.fav-dot { color: var(--accent); font-size: 12px; margin-left: 6px; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.chip-btn {
|
||||
padding: 7px 12px; border-radius: 999px; font-size: 12px;
|
||||
background: var(--card); border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.chip-btn.is-on { color: #1a1520; background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.detail-hero {
|
||||
border-radius: 20px; padding: 18px; margin-bottom: 14px;
|
||||
background: linear-gradient(160deg, rgba(240,198,116,.18), transparent 55%), var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.detail-hero .emoji { font-size: 36px; }
|
||||
.detail-hero h1 { font-size: 24px; margin: 8px 0 4px; }
|
||||
.kv {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 12px 0;
|
||||
}
|
||||
.kv .cell {
|
||||
background: var(--card); border-radius: 14px; padding: 12px; border: 1px solid var(--border);
|
||||
}
|
||||
.kv .k { color: var(--muted); font-size: 11px; }
|
||||
.kv .v { display: block; margin-top: 4px; font-size: 16px; font-weight: 700; color: var(--accent); }
|
||||
|
||||
.hl { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 14px; }
|
||||
.hl span {
|
||||
font-size: 12px; padding: 6px 10px; border-radius: 999px;
|
||||
background: rgba(125, 211, 199, 0.1); color: #b7efe6;
|
||||
}
|
||||
|
||||
.article {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 16px; line-height: 1.7; font-size: 14px; color: #e8e2f4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.article h2, .article h3 { color: var(--text); margin: 14px 0 8px; font-size: 16px; }
|
||||
.article p { margin: 0 0 10px; }
|
||||
.article ul, .article ol { padding-left: 18px; margin-bottom: 10px; }
|
||||
.article li { margin: 4px 0; }
|
||||
.article table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 10px 0; }
|
||||
.article th, .article td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; }
|
||||
|
||||
.form {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 14px;
|
||||
}
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%; padding: 10px 12px; border-radius: 12px;
|
||||
background: rgba(0,0,0,.25); border: 1px solid var(--border);
|
||||
}
|
||||
.check-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.check-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
|
||||
padding: 12px 14px; text-align: left; width: 100%;
|
||||
}
|
||||
.check-item.is-on { border-color: rgba(240,198,116,.5); background: rgba(240,198,116,.08); }
|
||||
.check-box {
|
||||
width: 20px; height: 20px; border-radius: 6px; border: 1.5px solid var(--muted);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 12px;
|
||||
}
|
||||
.check-item.is-on .check-box { background: var(--accent); border-color: var(--accent); color: #1a1520; }
|
||||
|
||||
.lifestyle {
|
||||
display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.life-card {
|
||||
min-width: 140px; flex-shrink: 0;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 12px; text-align: left;
|
||||
}
|
||||
.life-card.is-on { border-color: var(--accent); }
|
||||
.life-detail {
|
||||
margin-top: 10px; padding: 12px; border-radius: 14px;
|
||||
background: rgba(240,198,116,.08); color: #e8dfc8; font-size: 13px; line-height: 1.55;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 10px 0 14px; padding: 10px 12px; border-radius: 12px;
|
||||
background: rgba(240,198,116,.1); color: #e8d7a8; font-size: 12px; line-height: 1.5;
|
||||
}
|
||||
.empty { text-align: center; color: var(--muted); padding: 28px 10px; font-size: 13px; }
|
||||
|
||||
.map-board {
|
||||
position: relative; height: 220px; border-radius: 18px; overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 20% 40%, rgba(125,211,199,.15), transparent 30%),
|
||||
radial-gradient(circle at 70% 35%, rgba(240,198,116,.12), transparent 28%),
|
||||
linear-gradient(180deg, #17141f, #0f0d15);
|
||||
border: 1px solid var(--border); margin-bottom: 12px;
|
||||
}
|
||||
.map-pin {
|
||||
position: absolute; transform: translate(-50%, -100%);
|
||||
font-size: 18px; filter: drop-shadow(0 2px 4px rgba(0,0,0,.5));
|
||||
}
|
||||
.map-pin span {
|
||||
display: block; margin-top: 2px; font-size: 9px; color: var(--text);
|
||||
background: rgba(0,0,0,.45); padding: 1px 4px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.sheet { position: fixed; inset: 0; z-index: 60; }
|
||||
.sheet-mask { position: absolute; inset: 0; background: rgba(0,0,0,.55); }
|
||||
.sheet-panel {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
max-height: 78%; overflow: auto;
|
||||
background: #16131e; border-radius: 20px 20px 0 0;
|
||||
padding: 10px 16px calc(20px + var(--safe-b));
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sheet-handle {
|
||||
width: 42px; height: 4px; border-radius: 99px; background: rgba(255,255,255,.2);
|
||||
margin: 4px auto 14px;
|
||||
}
|
||||
|
||||
.compare-pick { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.compare-pick select { flex: 1; padding: 10px; border-radius: 12px; background: rgba(0,0,0,.25); border: 1px solid var(--border); }
|
||||
.compare-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.compare-table th, .compare-table td { border-bottom: 1px solid var(--border); padding: 10px 6px; text-align: left; }
|
||||
.compare-table th { color: var(--muted); font-weight: 500; }
|
||||
|
||||
.quote {
|
||||
background: var(--card); border-left: 3px solid var(--accent);
|
||||
border-radius: 0 14px 14px 0; padding: 12px 14px; margin-bottom: 10px;
|
||||
}
|
||||
.quote p { font-size: 13px; line-height: 1.55; }
|
||||
.quote .who { margin-top: 8px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
@ -1,40 +1,639 @@
|
||||
/**
|
||||
* Official XHS miniTool bridge helpers (Chrome 61 / ES2017).
|
||||
* Prefer window.xhs.miniTool Storage (9.46+); fall back to localStorage.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
var STORAGE_MIN = 9460;
|
||||
var COMMENT_MIN = 9490;
|
||||
var FILE_MIN = 9490;
|
||||
|
||||
function readBuildVersion(launchOptions) {
|
||||
var env = launchOptions && launchOptions.miniToolEnv;
|
||||
return Number(env && env.buildVersion) || 0;
|
||||
}
|
||||
|
||||
function getClientVersion(buildVersion) {
|
||||
return Math.floor((Number(buildVersion) || 0) / 1000);
|
||||
}
|
||||
|
||||
function isClientVersionAtLeast(buildVersion, minimum) {
|
||||
return getClientVersion(buildVersion) >= minimum;
|
||||
}
|
||||
|
||||
function getMiniTool() {
|
||||
return global.xhs && global.xhs.miniTool ? global.xhs.miniTool : null;
|
||||
}
|
||||
function setLocalData(key, data) {
|
||||
var serialized = JSON.stringify(data);
|
||||
|
||||
function getBuildVersion() {
|
||||
var xhs = global.xhs;
|
||||
var sync = readBuildVersion(xhs && xhs.launchOptions);
|
||||
if (sync) {
|
||||
return Promise.resolve(sync);
|
||||
}
|
||||
var mt = getMiniTool();
|
||||
if (mt && typeof mt.setStorage === 'function') {
|
||||
return Promise.resolve(mt.setStorage({ key: key, data: serialized }))
|
||||
.then(function () { return true; })
|
||||
.catch(function () {
|
||||
try { localStorage.setItem(key, serialized); return true; } catch (e) { return false; }
|
||||
});
|
||||
if (!mt || typeof mt.getLaunchOptions !== "function") {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
try { localStorage.setItem(key, serialized); return Promise.resolve(true); } catch (e) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
function getLocalData(key) {
|
||||
var mt = getMiniTool();
|
||||
if (mt && typeof mt.getStorage === 'function') {
|
||||
return Promise.resolve(mt.getStorage({ key: key }))
|
||||
.then(function (res) {
|
||||
var raw = res && (res.data || res);
|
||||
if (typeof raw === 'string') {
|
||||
try { return JSON.parse(raw); } catch (e) { return null; }
|
||||
}
|
||||
return raw || null;
|
||||
return Promise.resolve(mt.getLaunchOptions())
|
||||
.then(function (opts) {
|
||||
return readBuildVersion(opts);
|
||||
})
|
||||
.catch(function () {
|
||||
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; }
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
try { return Promise.resolve(JSON.parse(localStorage.getItem(key) || 'null')); } catch (e) {
|
||||
|
||||
function setLocalData(key, data, encrypt) {
|
||||
var serialized;
|
||||
try {
|
||||
serialized = JSON.stringify(data);
|
||||
} catch (e) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (typeof serialized !== "string") return Promise.resolve(false);
|
||||
var useEncrypt = !!encrypt;
|
||||
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
isClientVersionAtLeast(buildVersion, STORAGE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.setStorage === "function"
|
||||
) {
|
||||
var payload = { key: key, data: serialized };
|
||||
if (useEncrypt) payload.encrypt = true;
|
||||
return Promise.resolve(mt.setStorage(payload))
|
||||
.then(function () {
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return writeBrowser(key, serialized);
|
||||
});
|
||||
}
|
||||
return writeBrowser(key, serialized);
|
||||
});
|
||||
}
|
||||
|
||||
function writeBrowser(key, serialized) {
|
||||
try {
|
||||
global.localStorage.setItem(key, serialized);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalData(key, fallback, encrypt) {
|
||||
var useEncrypt = !!encrypt;
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
isClientVersionAtLeast(buildVersion, STORAGE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.getStorage === "function"
|
||||
) {
|
||||
var payload = { key: key };
|
||||
if (useEncrypt) payload.encrypt = true;
|
||||
return Promise.resolve(mt.getStorage(payload))
|
||||
.then(function (res) {
|
||||
var raw = res && res.data;
|
||||
if (raw != null && raw !== "") {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
// Retry without encrypt (migration), then browser
|
||||
if (useEncrypt) {
|
||||
return Promise.resolve(mt.getStorage({ key: key }))
|
||||
.then(function (res2) {
|
||||
var raw2 = res2 && res2.data;
|
||||
if (raw2 != null && raw2 !== "") {
|
||||
try {
|
||||
var parsed = JSON.parse(raw2);
|
||||
return setLocalData(key, parsed, true).then(function () {
|
||||
return parsed;
|
||||
});
|
||||
} catch (e2) {
|
||||
return readBrowser(key, fallback);
|
||||
}
|
||||
}
|
||||
return readBrowser(key, fallback);
|
||||
})
|
||||
.catch(function () {
|
||||
return readBrowser(key, fallback);
|
||||
});
|
||||
}
|
||||
var fromBrowser = readBrowser(key, null);
|
||||
if (fromBrowser == null) return fallback;
|
||||
return writeNative(mt, key, JSON.stringify(fromBrowser), false).then(
|
||||
function () {
|
||||
return fromBrowser;
|
||||
},
|
||||
function () {
|
||||
return fromBrowser;
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(function () {
|
||||
return readBrowser(key, fallback);
|
||||
});
|
||||
}
|
||||
return readBrowser(key, fallback);
|
||||
});
|
||||
}
|
||||
|
||||
function writeNative(mt, key, serialized, encrypt) {
|
||||
if (!mt || typeof mt.setStorage !== "function") {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
var payload = { key: key, data: serialized };
|
||||
if (encrypt) payload.encrypt = true;
|
||||
return Promise.resolve(mt.setStorage(payload)).then(
|
||||
function () {
|
||||
return true;
|
||||
},
|
||||
function () {
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readBrowser(key, fallback) {
|
||||
try {
|
||||
var raw = global.localStorage.getItem(key);
|
||||
if (raw == null || raw === "") return fallback;
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function removeLocalData(key) {
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
isClientVersionAtLeast(buildVersion, STORAGE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.removeStorage === "function"
|
||||
) {
|
||||
return Promise.resolve(mt.removeStorage({ key: key }))
|
||||
.then(function () {
|
||||
removeBrowser(key);
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return removeBrowser(key);
|
||||
});
|
||||
}
|
||||
return removeBrowser(key);
|
||||
});
|
||||
}
|
||||
|
||||
function clearLocalData() {
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
isClientVersionAtLeast(buildVersion, STORAGE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.clearStorage === "function"
|
||||
) {
|
||||
return Promise.resolve(mt.clearStorage())
|
||||
.then(function () {
|
||||
clearBrowser();
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return clearBrowser();
|
||||
});
|
||||
}
|
||||
return clearBrowser();
|
||||
});
|
||||
}
|
||||
|
||||
function removeBrowser(key) {
|
||||
try {
|
||||
global.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearBrowser() {
|
||||
try {
|
||||
global.localStorage.clear();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getLaunchOptions() {
|
||||
var xhs = global.xhs;
|
||||
if (xhs && xhs.launchOptions) {
|
||||
return Promise.resolve(xhs.launchOptions);
|
||||
}
|
||||
var mt = getMiniTool();
|
||||
if (!mt || typeof mt.getLaunchOptions !== "function") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(mt.getLaunchOptions()).catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
global.NomadroXhs = { setLocalData: setLocalData, getLocalData: getLocalData };
|
||||
|
||||
/** Read miniToolSnapshotInfo from launch options (comment re-entry, §3.9). */
|
||||
function readSnapshotInfo(launchOptions) {
|
||||
if (!launchOptions) return null;
|
||||
var raw =
|
||||
launchOptions.miniToolSnapshotInfo ||
|
||||
(launchOptions.query && launchOptions.query.miniToolSnapshotInfo) ||
|
||||
(launchOptions.referrerInfo &&
|
||||
launchOptions.referrerInfo.miniToolSnapshotInfo) ||
|
||||
(launchOptions.extraData && launchOptions.extraData.miniToolSnapshotInfo);
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "object") return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function callApi(name, options) {
|
||||
var mt = getMiniTool();
|
||||
if (!mt || typeof mt[name] !== "function") {
|
||||
return Promise.reject({ errMsg: name + ":fail not available" });
|
||||
}
|
||||
return Promise.resolve(mt[name](options || {}));
|
||||
}
|
||||
|
||||
function writeTempFile(dataUrl) {
|
||||
return callApi("writeTempFile", { data: dataUrl }).then(function (res) {
|
||||
return (res && res.filePath) || dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function saveImageToPhotosAlbum(filePath) {
|
||||
return callApi("saveImageToPhotosAlbum", { filePath: filePath });
|
||||
}
|
||||
|
||||
function postNote(opts) {
|
||||
return callApi("postNote", opts);
|
||||
}
|
||||
|
||||
function canPostComment(buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
return (
|
||||
isClientVersionAtLeast(buildVersion, COMMENT_MIN) &&
|
||||
mt &&
|
||||
typeof mt.interactionOpenApi === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function postComment(payload, saveToAlbum) {
|
||||
return callApi("interactionOpenApi", {
|
||||
payload: payload,
|
||||
saveToAlbum: saveToAlbum !== false
|
||||
});
|
||||
}
|
||||
|
||||
function getStorageInfo() {
|
||||
return getBuildVersion().then(function (buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!isClientVersionAtLeast(buildVersion, STORAGE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.getStorageInfo !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Promise.resolve(mt.getStorageInfo()).catch(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function canUseFileSystem(buildVersion) {
|
||||
var mt = getMiniTool();
|
||||
return (
|
||||
isClientVersionAtLeast(buildVersion, FILE_MIN) &&
|
||||
mt &&
|
||||
typeof mt.writeFile === "function" &&
|
||||
typeof mt.readFile === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function getUserDataPath() {
|
||||
return getLaunchOptions().then(function (opts) {
|
||||
var env = opts && opts.miniToolEnv;
|
||||
return (env && env.userDataPath) || "";
|
||||
});
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
var mt = getMiniTool();
|
||||
if (!mt || typeof mt.mkdir !== "function") {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(mt.mkdir({ dirPath: dirPath, recursive: true }))
|
||||
.then(function () {
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function writeUtf8File(relativePath, data) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
if (!canUseFileSystem(bv)) {
|
||||
return Promise.reject({ errMsg: "writeFile:fail version" });
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return Promise.reject({ errMsg: "writeFile:fail no path" });
|
||||
var rel = String(relativePath || "").replace(/^\/+/, "");
|
||||
var parts = rel.split("/");
|
||||
var dir =
|
||||
parts.length > 1 ? root + "/" + parts.slice(0, -1).join("/") : root;
|
||||
var filePath = root + "/" + rel;
|
||||
return ensureDir(dir).then(function () {
|
||||
return callApi("writeFile", {
|
||||
filePath: filePath,
|
||||
data: typeof data === "string" ? data : JSON.stringify(data),
|
||||
encoding: "utf8"
|
||||
}).then(function () {
|
||||
return filePath;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function appendUtf8File(relativePath, data) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
if (!canUseFileSystem(bv)) {
|
||||
return Promise.reject({ errMsg: "appendFile:fail version" });
|
||||
}
|
||||
var mt = getMiniTool();
|
||||
if (!mt || typeof mt.appendFile !== "function") {
|
||||
// Fallback: read + write
|
||||
return readUtf8File(relativePath).then(function (prev) {
|
||||
return writeUtf8File(
|
||||
relativePath,
|
||||
(prev || "") + (typeof data === "string" ? data : String(data))
|
||||
);
|
||||
});
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return Promise.reject({ errMsg: "appendFile:fail no path" });
|
||||
var rel = String(relativePath || "").replace(/^\/+/, "");
|
||||
var parts = rel.split("/");
|
||||
var dir =
|
||||
parts.length > 1 ? root + "/" + parts.slice(0, -1).join("/") : root;
|
||||
var filePath = root + "/" + rel;
|
||||
return ensureDir(dir).then(function () {
|
||||
return callApi("appendFile", {
|
||||
filePath: filePath,
|
||||
data: typeof data === "string" ? data : String(data),
|
||||
encoding: "utf8"
|
||||
}).then(function () {
|
||||
return filePath;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function postVideoNote(opts) {
|
||||
var videoRes = { video_url: opts.videoPath };
|
||||
if (opts.coverPath) videoRes.cover_url = opts.coverPath;
|
||||
return callApi("postNote", {
|
||||
title: opts.title,
|
||||
content: opts.content,
|
||||
pageType: "video_publish",
|
||||
mediaInfo: {
|
||||
video_resources: videoRes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readUtf8File(relativePath) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
if (!canUseFileSystem(bv)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return null;
|
||||
var filePath = root + "/" + String(relativePath || "").replace(/^\/+/, "");
|
||||
return callApi("readFile", { filePath: filePath, encoding: "utf8" })
|
||||
.then(function (res) {
|
||||
return res && res.data != null ? res.data : null;
|
||||
})
|
||||
.catch(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Write dataURL to temp, then optionally persist via saveFile (9.49+). */
|
||||
function persistImage(dataUrl, relativePath) {
|
||||
return writeTempFile(dataUrl).then(function (tempPath) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!relativePath ||
|
||||
!isClientVersionAtLeast(bv, FILE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.saveFile !== "function"
|
||||
) {
|
||||
return tempPath;
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return tempPath;
|
||||
var dest = root + "/" + String(relativePath).replace(/^\/+/, "");
|
||||
var dir = dest.replace(/\/[^/]+$/, "");
|
||||
return ensureDir(dir).then(function () {
|
||||
return callApi("saveFile", {
|
||||
tempFilePath: tempPath,
|
||||
filePath: dest
|
||||
})
|
||||
.then(function (res) {
|
||||
return (res && res.savedFilePath) || dest || tempPath;
|
||||
})
|
||||
.catch(function () {
|
||||
return tempPath;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFileStorageInfo() {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!isClientVersionAtLeast(bv, FILE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.getFileStorageInfo !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Promise.resolve(mt.getFileStorageInfo()).catch(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unlinkFile(relativeOrAbsPath) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!isClientVersionAtLeast(bv, FILE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.unlink !== "function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
var path = String(relativeOrAbsPath || "");
|
||||
if (!path) return false;
|
||||
var p = Promise.resolve(path);
|
||||
if (path.indexOf("/") !== 0 && path.indexOf("usr") !== 0) {
|
||||
p = getUserDataPath().then(function (root) {
|
||||
return root ? root + "/" + path.replace(/^\/+/, "") : path;
|
||||
});
|
||||
}
|
||||
return p.then(function (filePath) {
|
||||
return callApi("unlink", { filePath: filePath })
|
||||
.then(function () {
|
||||
return true;
|
||||
})
|
||||
.catch(function () {
|
||||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readDirList(relativeDir) {
|
||||
return getBuildVersion().then(function (bv) {
|
||||
var mt = getMiniTool();
|
||||
if (
|
||||
!isClientVersionAtLeast(bv, FILE_MIN) ||
|
||||
!mt ||
|
||||
typeof mt.readDir !== "function"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return getUserDataPath().then(function (root) {
|
||||
if (!root) return [];
|
||||
var dirPath =
|
||||
root +
|
||||
(relativeDir
|
||||
? "/" + String(relativeDir).replace(/^\/+|\/+$/g, "")
|
||||
: "");
|
||||
return callApi("readDir", { dirPath: dirPath })
|
||||
.then(function (res) {
|
||||
var files = (res && res.files) || [];
|
||||
return files.map(function (f) {
|
||||
if (typeof f === "string") {
|
||||
return { name: f, path: dirPath + "/" + f };
|
||||
}
|
||||
var name = f.name || f.fileName || String(f);
|
||||
return {
|
||||
name: name,
|
||||
path: f.path || f.filePath || dirPath + "/" + name,
|
||||
isDir: !!(f.isDir || f.isDirectory)
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
return [];
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupShareDir() {
|
||||
return readDirList("share").then(function (files) {
|
||||
var chain = Promise.resolve(0);
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
(function (file) {
|
||||
if (file.isDir) return;
|
||||
chain = chain.then(function (n) {
|
||||
return callApi("unlink", { filePath: file.path })
|
||||
.then(function () {
|
||||
return n + 1;
|
||||
})
|
||||
.catch(function () {
|
||||
return n;
|
||||
});
|
||||
});
|
||||
})(files[i]);
|
||||
}
|
||||
return chain;
|
||||
});
|
||||
}
|
||||
|
||||
/** Convert <input type=file> File / Blob to data URL (offline, no network). */
|
||||
function blobToDataUrl(blob) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (!blob) {
|
||||
reject({ errMsg: "blobToDataUrl:fail empty" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = function () {
|
||||
reject({ errMsg: "blobToDataUrl:fail read" });
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (e) {
|
||||
reject({ errMsg: "blobToDataUrl:fail " + (e && e.message) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.XhsBridge = {
|
||||
getBuildVersion: getBuildVersion,
|
||||
getClientVersion: getClientVersion,
|
||||
isClientVersionAtLeast: isClientVersionAtLeast,
|
||||
setLocalData: setLocalData,
|
||||
getLocalData: getLocalData,
|
||||
removeLocalData: removeLocalData,
|
||||
clearLocalData: clearLocalData,
|
||||
getStorageInfo: getStorageInfo,
|
||||
getFileStorageInfo: getFileStorageInfo,
|
||||
getLaunchOptions: getLaunchOptions,
|
||||
readSnapshotInfo: readSnapshotInfo,
|
||||
writeTempFile: writeTempFile,
|
||||
saveImageToPhotosAlbum: saveImageToPhotosAlbum,
|
||||
postNote: postNote,
|
||||
canPostComment: canPostComment,
|
||||
postComment: postComment,
|
||||
canUseFileSystem: canUseFileSystem,
|
||||
getUserDataPath: getUserDataPath,
|
||||
writeUtf8File: writeUtf8File,
|
||||
appendUtf8File: appendUtf8File,
|
||||
readUtf8File: readUtf8File,
|
||||
persistImage: persistImage,
|
||||
unlinkFile: unlinkFile,
|
||||
readDirList: readDirList,
|
||||
cleanupShareDir: cleanupShareDir,
|
||||
blobToDataUrl: blobToDataUrl,
|
||||
postVideoNote: postVideoNote,
|
||||
getMiniTool: getMiniTool,
|
||||
STORAGE_MIN: STORAGE_MIN,
|
||||
COMMENT_MIN: COMMENT_MIN,
|
||||
FILE_MIN: FILE_MIN
|
||||
};
|
||||
})(window);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user