diff --git a/README.md b/README.md
index 48d3db7..a69b46f 100644
--- a/README.md
+++ b/README.md
@@ -96,7 +96,7 @@ docker compose up -d
| 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 |
| 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 |
| 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 |
-| 🗓️ 行程规划 | 多城市规划,预算汇总,分享/导入链接 |
+| 🗓️ 行程规划 | 首页快速规划;完整能力见「旅居计划中心」 |
| 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 |
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
@@ -136,6 +136,7 @@ docker compose up -d
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
+| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 |
| 📜 更新日志 | `/changelog` 迭代记录 |
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
| 🥗 饮食饮水 | 各城饮水与街头饮食注意 |
@@ -193,7 +194,9 @@ docker compose up -d
| `/` | 首页(全部板块) |
| `/login` | 登录 / 注册 |
| `/profile` | 用户中心 |
-| `/destinations/[slug]` | 目的地详情(城市画像、加入行程) |
+| `/destinations/[slug]` | 目的地详情(城市画像、加入计划) |
+| `/plan` | 旅居计划中心(时间轴 / 预算 / 清单 / 分享) |
+| `/tools` | 工具箱聚合 |
| `/blog/[slug]` | 博客详情(阅读进度) |
## 环境变量
diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx
index b536581..dd3c927 100644
--- a/frontend/src/app/changelog/page.tsx
+++ b/frontend/src/app/changelog/page.tsx
@@ -8,6 +8,15 @@ export const metadata: Metadata = {
};
const LOGS = [
+ {
+ date: "2026-08-29",
+ tag: "核心功能",
+ items: [
+ "全新「旅居计划中心」/plan:多城时间轴、出发月推算、站内备注、停留签证提醒",
+ "月预算上限对照、出发就绪清单、行程内城市对比、分享/导出 Markdown",
+ "导航与目的地「加入计划」、个人中心、全局搜索统一指向计划中心",
+ ],
+ },
{
date: "2026-08-29",
tag: "大更新",
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 56de498..d723897 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -8680,3 +8680,320 @@ img { max-width: 100%; display: block; }
.kit-row, .deposit-top, .deposit-grid, .kit-grid-3 { grid-template-columns: 1fr; }
.kit-stat { text-align: left; }
}
+
+/* ===== Move Plan Hub (/plan) ===== */
+.plan-page {
+ padding: 100px 0 80px;
+ min-height: 70vh;
+}
+.plan-loading {
+ color: var(--text-secondary);
+ padding: 48px 0;
+}
+.plan-hero {
+ margin-bottom: 28px;
+}
+.plan-hero p {
+ max-width: 640px;
+ color: var(--text-secondary);
+ margin: 8px 0 20px;
+ line-height: 1.6;
+}
+.plan-title-input {
+ display: block;
+ width: 100%;
+ max-width: 720px;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid transparent;
+ color: var(--text-primary);
+ font-size: clamp(1.8rem, 4vw, 2.6rem);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ padding: 4px 0;
+ margin: 8px 0 0;
+}
+.plan-title-input:focus {
+ outline: none;
+ border-bottom-color: var(--accent-2);
+}
+.plan-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+.plan-meta-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+.plan-meta-field input {
+ min-width: 180px;
+ padding: 10px 12px;
+ border-radius: var(--radius-md);
+ border: var(--border-glass);
+ background: var(--bg-card);
+ color: var(--text-primary);
+}
+.plan-stats {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 12px;
+ margin-bottom: 20px;
+}
+.plan-stat {
+ padding: 16px 18px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-lg);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.plan-stat span {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+.plan-stat strong {
+ font-size: 1.25rem;
+}
+.plan-warn-text { color: var(--accent-1) !important; }
+.plan-alert {
+ margin-bottom: 20px;
+ padding: 12px 16px;
+ border-radius: var(--radius-md);
+ border: 1px solid color-mix(in srgb, var(--accent-1) 40%, transparent);
+ background: color-mix(in srgb, var(--accent-1) 12%, transparent);
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+.plan-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.9fr);
+ gap: 24px;
+ align-items: start;
+}
+.plan-panel {
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ padding: 22px 24px;
+ margin-bottom: 20px;
+ backdrop-filter: blur(16px);
+}
+.plan-panel h2 {
+ font-size: 1.1rem;
+ margin: 0;
+}
+.plan-panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+}
+.plan-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.plan-empty {
+ text-align: center;
+ padding: 40px 16px;
+ color: var(--text-secondary);
+}
+.plan-empty-icon { font-size: 2.8rem; display: block; margin-bottom: 8px; }
+.plan-empty .btn { margin-top: 12px; }
+.plan-timeline {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.plan-stop {
+ display: grid;
+ grid-template-columns: 44px 1fr;
+ gap: 12px;
+}
+.plan-stop-rail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+.plan-stop-dot {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background: var(--bg-secondary, rgba(255,255,255,0.06));
+ border: var(--border-glass);
+ font-size: 1.2rem;
+ flex-shrink: 0;
+}
+.plan-stop-line {
+ width: 2px;
+ flex: 1;
+ min-height: 24px;
+ background: linear-gradient(to bottom, var(--accent-2), transparent);
+ opacity: 0.5;
+ margin: 4px 0;
+}
+.plan-stop-body {
+ padding-bottom: 22px;
+}
+.plan-stop-top {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+}
+.plan-stop-top strong a {
+ color: var(--text-primary);
+ text-decoration: none;
+}
+.plan-stop-top strong a:hover { color: var(--accent-2); }
+.plan-stop-country { font-weight: 500; color: var(--text-secondary); }
+.plan-stop-range {
+ display: block;
+ margin-top: 4px;
+ font-size: 0.8rem;
+ color: var(--accent-2);
+}
+.plan-stop-tools {
+ display: flex;
+ gap: 4px;
+}
+.plan-stop-tools button {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ border: var(--border-glass);
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+.plan-stop-tools button:disabled { opacity: 0.35; cursor: not-allowed; }
+.plan-stop-tools button:not(:disabled):hover {
+ color: var(--text-primary);
+ border-color: var(--accent-2);
+}
+.plan-remove:hover { color: var(--accent-1) !important; }
+.plan-stop-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin: 10px 0;
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+}
+.plan-stop-controls input {
+ width: 56px;
+ margin: 0 6px;
+ padding: 6px 8px;
+ border-radius: 8px;
+ border: var(--border-glass);
+ background: transparent;
+ color: var(--text-primary);
+}
+.plan-stop-cost {
+ font-weight: 700;
+ color: var(--text-primary);
+}
+.plan-stop-unit { font-size: 0.8rem; }
+.plan-note {
+ width: 100%;
+ resize: vertical;
+ min-height: 56px;
+ padding: 10px 12px;
+ border-radius: var(--radius-md);
+ border: var(--border-glass);
+ background: transparent;
+ color: var(--text-primary);
+ font: inherit;
+ line-height: 1.45;
+}
+.plan-visa {
+ margin: 8px 0 0;
+ font-size: 0.82rem;
+ color: var(--text-secondary);
+ line-height: 1.45;
+}
+.plan-visa.warn {
+ color: var(--accent-1);
+}
+.plan-compare-wrap { overflow-x: auto; }
+.plan-compare {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.88rem;
+}
+.plan-compare th,
+.plan-compare td {
+ text-align: left;
+ padding: 10px 8px;
+ border-bottom: 1px solid color-mix(in srgb, var(--text-secondary) 20%, transparent);
+}
+.plan-compare th { color: var(--text-secondary); font-weight: 500; }
+.plan-ready-pill {
+ font-size: 0.8rem;
+ color: var(--accent-2);
+}
+.plan-ready-bar {
+ height: 6px;
+ border-radius: 99px;
+ background: color-mix(in srgb, var(--text-secondary) 20%, transparent);
+ margin-bottom: 16px;
+ overflow: hidden;
+}
+.plan-ready-bar > div {
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--accent-1), var(--accent-2));
+ transition: width 0.25s ease;
+}
+.plan-check-group { margin-bottom: 14px; }
+.plan-check-group h3 {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-secondary);
+ margin: 0 0 8px;
+}
+.plan-check-group ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.plan-check-group label {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+.plan-check-group input { margin-top: 3px; accent-color: var(--accent-2); }
+.plan-check-group strong { display: block; font-weight: 600; }
+.plan-check-group small {
+ display: block;
+ color: var(--text-secondary);
+ font-size: 0.75rem;
+ margin-top: 2px;
+}
+.plan-side .btn { width: 100%; margin-top: 8px; }
+
+@media (max-width: 960px) {
+ .plan-stats { grid-template-columns: repeat(2, 1fr); }
+ .plan-layout { grid-template-columns: 1fr; }
+}
+@media (max-width: 520px) {
+ .plan-stats { grid-template-columns: 1fr 1fr; }
+ .plan-page { padding-top: 88px; }
+}
diff --git a/frontend/src/app/plan/page.tsx b/frontend/src/app/plan/page.tsx
new file mode 100644
index 0000000..cdc7aec
--- /dev/null
+++ b/frontend/src/app/plan/page.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from "next";
+import { api } from "@/lib/api";
+import type { Destination } from "@/lib/types";
+import MovePlanClient from "@/components/MovePlanClient";
+import SiteShell from "@/components/SiteShell";
+
+export const metadata: Metadata = {
+ title: "旅居计划中心 · nomadro",
+ description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单——一页做完旅居规划",
+};
+
+export const revalidate = 60;
+
+export default async function PlanPage() {
+ let destinations: Destination[] = [];
+ try {
+ destinations = await api.getDestinations();
+ } catch {
+ destinations = [];
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx
index d301b41..1bd66ed 100644
--- a/frontend/src/app/profile/page.tsx
+++ b/frontend/src/app/profile/page.tsx
@@ -144,7 +144,7 @@ export default function ProfilePage() {
🗺️
还没有规划行程
-
去规划 →
+
去规划 →
) : (
@@ -162,7 +162,7 @@ export default function ProfilePage() {
总计 {trip.reduce((s, t) => s + t.months, 0)} 个月 ·
¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()}
- 编辑行程 →
+ 打开旅居计划中心 →
)}
@@ -171,10 +171,10 @@ export default function ProfilePage() {
🚀 快捷入口
🌍 浏览目的地
+ 🗓️ 旅居计划
📋 签证指南
💻 联合办公
📝 阅读博客
- 🗓️ 行程规划
📊 就绪度测评
diff --git a/frontend/src/components/DestinationDetailClient.tsx b/frontend/src/components/DestinationDetailClient.tsx
index d484893..0f9d26f 100644
--- a/frontend/src/components/DestinationDetailClient.tsx
+++ b/frontend/src/components/DestinationDetailClient.tsx
@@ -42,10 +42,10 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
toast("该城市已在行程中", "info");
return;
}
- trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1 });
+ trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1, note: "" });
saveTrip(trip);
setAdded(true);
- toast(`${dest.emoji} ${dest.name} 已加入行程 🗓️`);
+ toast(`${dest.emoji} ${dest.name} 已加入旅居计划`);
};
const share = async () => {
@@ -66,12 +66,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
return (
<>
-
+ {added ? (
+ ✓ 已在计划中 · 打开 →
+ ) : (
+
+ )}
+ 🗓️ 计划中心
🧮 算费用
- 🕐 看时区
diff --git a/frontend/src/components/GlobalSearch.tsx b/frontend/src/components/GlobalSearch.tsx
index 81c37e9..addd178 100644
--- a/frontend/src/components/GlobalSearch.tsx
+++ b/frontend/src/components/GlobalSearch.tsx
@@ -16,6 +16,7 @@ const TYPE_LABELS: Record
= {
};
const PAGE_RESULTS: SearchResult[] = [
+ { type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx
index 78eb78c..0b5daec 100644
--- a/frontend/src/components/KeyboardShortcuts.tsx
+++ b/frontend/src/components/KeyboardShortcuts.tsx
@@ -11,6 +11,7 @@ const SHORTCUTS = [
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
{ keys: ["T"], label: "打开工具箱", icon: "🛠️" },
+ { keys: ["P"], label: "旅居计划中心", icon: "🗓️" },
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
@@ -41,6 +42,10 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
e.preventDefault();
window.location.href = "/tools";
}
+ if (e.key === "p" && !typing && !e.metaKey && !e.ctrlKey) {
+ e.preventDefault();
+ window.location.href = "/plan";
+ }
if (e.key === "Escape" && open) setOpen(false);
};
window.addEventListener("keydown", onKey);
diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx
new file mode 100644
index 0000000..50c3d80
--- /dev/null
+++ b/frontend/src/components/MovePlanClient.tsx
@@ -0,0 +1,446 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
+import { useToast } from "@/lib/toast";
+import { loadTrip, saveTrip } from "@/lib/tripStorage";
+import {
+ loadPlanMeta,
+ savePlanMeta,
+ rangeLabel,
+ stayHint,
+ MOVE_CHECKLIST,
+ type PlanMeta,
+} from "@/lib/planMeta";
+import type { Destination, TripItem } from "@/lib/types";
+
+interface Props {
+ destinations: Destination[];
+}
+
+function scoreDest(d: Destination) {
+ const nomads = parseInt(d.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
+ return {
+ value: Math.round((12000 - d.cost) / 120),
+ speed: Math.round(d.speed / 2),
+ climate: Math.round(100 - Math.abs(d.temperature - 24) * 4),
+ rating: Math.round(d.rating * 10),
+ community: Math.min(Math.round(nomads / 150), 100),
+ };
+}
+
+export default function MovePlanClient({ destinations }: Props) {
+ const { toast } = useToast();
+ const [trip, setTrip] = useState([]);
+ const [meta, setMeta] = useState(() => ({
+ title: "我的旅居计划",
+ startMonth: "",
+ monthlyBudget: 0,
+ checklist: {},
+ }));
+ const [selected, setSelected] = useState("");
+ const [months, setMonths] = useState(1);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const shared = params.get("trip");
+ if (shared) {
+ try {
+ const decoded = JSON.parse(atob(shared)) as TripItem[];
+ if (Array.isArray(decoded) && decoded.length > 0) {
+ setTrip(decoded);
+ saveTrip(decoded);
+ toast("已导入分享的行程");
+ window.history.replaceState({}, "", "/plan");
+ }
+ } catch { /* ignore */ }
+ } else {
+ setTrip(loadTrip());
+ }
+ setMeta(loadPlanMeta());
+ setReady(true);
+ }, [toast]);
+
+ const persistTrip = (items: TripItem[]) => {
+ setTrip(items);
+ saveTrip(items);
+ };
+
+ const persistMeta = (next: PlanMeta) => {
+ setMeta(next);
+ savePlanMeta(next);
+ };
+
+ const totalCost = trip.reduce((s, t) => s + t.cost * t.months, 0);
+ const totalMonths = trip.reduce((s, t) => s + t.months, 0);
+ const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0;
+
+ const checkedCount = MOVE_CHECKLIST.filter((c) => meta.checklist[c.id]).length;
+ const readiness = Math.round((checkedCount / MOVE_CHECKLIST.length) * 100);
+
+ const budgetOk = meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget;
+
+ const timeline = useMemo(() => {
+ let offset = 0;
+ return trip.map((t) => {
+ const label = rangeLabel(meta.startMonth, offset, t.months);
+ const row = { ...t, range: label, offset, visa: stayHint(t.country, t.months) };
+ offset += t.months;
+ return row;
+ });
+ }, [trip, meta.startMonth]);
+
+ const compareRows = useMemo(() => {
+ return trip
+ .map((t) => destinations.find((d) => d.slug === t.slug))
+ .filter((d): d is Destination => !!d)
+ .map((d) => ({ dest: d, scores: scoreDest(d) }));
+ }, [trip, destinations]);
+
+ const checklistByCat = useMemo(() => {
+ const map = new Map();
+ for (const item of MOVE_CHECKLIST) {
+ const list = map.get(item.category) || [];
+ list.push(item);
+ map.set(item.category, list);
+ }
+ return [...map.entries()];
+ }, []);
+
+ const addCity = () => {
+ const dest = destinations.find((d) => d.slug === selected);
+ if (!dest || trip.some((t) => t.slug === dest.slug)) return;
+ persistTrip([
+ ...trip,
+ {
+ slug: dest.slug,
+ name: dest.name,
+ country: dest.country,
+ emoji: dest.emoji,
+ cost: dest.cost,
+ months,
+ note: "",
+ },
+ ]);
+ setSelected("");
+ setMonths(1);
+ toast(`${dest.emoji} ${dest.name} 已加入计划`);
+ };
+
+ const updateItem = (slug: string, patch: Partial) =>
+ persistTrip(trip.map((t) => (t.slug === slug ? { ...t, ...patch } : t)));
+
+ const removeCity = (slug: string) => persistTrip(trip.filter((t) => t.slug !== slug));
+
+ const move = (index: number, dir: -1 | 1) => {
+ const next = index + dir;
+ if (next < 0 || next >= trip.length) return;
+ const copy = [...trip];
+ [copy[index], copy[next]] = [copy[next], copy[index]];
+ persistTrip(copy);
+ };
+
+ const toggleCheck = (id: string) => {
+ persistMeta({
+ ...meta,
+ checklist: { ...meta.checklist, [id]: !meta.checklist[id] },
+ });
+ };
+
+ const shareTrip = async () => {
+ const encoded = btoa(JSON.stringify(trip));
+ const url = `${window.location.origin}/plan?trip=${encoded}`;
+ await navigator.clipboard.writeText(url);
+ toast("分享链接已复制");
+ };
+
+ const exportTrip = () => {
+ const lines = [
+ `# ${meta.title}`,
+ meta.startMonth ? `出发月:${meta.startMonth}` : "",
+ "",
+ ...timeline.map((t, i) => {
+ const range = t.range ? `(${t.range})` : "";
+ const note = t.note ? `\n 备注:${t.note}` : "";
+ return `${i + 1}. ${t.emoji} **${t.name}, ${t.country}** — ${t.months} 个月 · ¥${(t.cost * t.months).toLocaleString()}${range}${note}`;
+ }),
+ "",
+ `> 总计 **${totalMonths} 个月** · **¥${totalCost.toLocaleString()}** · 均月 ¥${avgMonth.toLocaleString()}`,
+ `> 出发就绪度 **${readiness}%**(${checkedCount}/${MOVE_CHECKLIST.length})`,
+ "",
+ `_由 nomadro 旅居计划中心生成 · ${new Date().toLocaleDateString("zh-CN")}_`,
+ ].filter(Boolean);
+ const blob = new Blob([lines.join("\n")], { type: "text/markdown;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `nomadro-plan-${Date.now()}.md`;
+ a.click();
+ URL.revokeObjectURL(url);
+ toast("已下载 Markdown 计划");
+ };
+
+ const copyPlain = async () => {
+ const text = timeline
+ .map((t, i) => `${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 ¥${(t.cost * t.months).toLocaleString()}${t.range ? ` (${t.range})` : ""}`)
+ .join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()} · 就绪 ${readiness}%`;
+ await navigator.clipboard.writeText(text);
+ toast("计划已复制");
+ };
+
+ if (!ready) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ 城市
+ {trip.length}
+
+
+ 总时长
+ {totalMonths} 月
+
+
+ 总预算
+ ¥{totalCost.toLocaleString()}
+
+
+ 均月开销
+
+ ¥{avgMonth.toLocaleString()}
+
+
+
+ 出发就绪
+ {readiness}%
+
+
+
+ {!budgetOk && (
+
+ 均月开销 ¥{avgMonth.toLocaleString()} 已超过你的月预算上限 ¥{meta.monthlyBudget.toLocaleString()}。
+ 可缩短高消费城市停留,或调高预算。
+
+ )}
+
+
+
+
+
+
路线时间轴
+
+
+
+
+
+
+
+
+ {trip.length === 0 ? (
+
+
🗺️
+
还没有城市。从目的地加入,或在下方添加第一站。
+
去选目的地 →
+
+ ) : (
+
+ {timeline.map((t, i) => (
+ -
+
+ {t.emoji}
+ {i < timeline.length - 1 && }
+
+
+
+
+
+ {t.name}
+ , {t.country}
+
+ {t.range && {t.range}}
+
+
+
+
+
+
+
+
+
+ ¥{(t.cost * t.months).toLocaleString()}
+ 约 ¥{t.cost.toLocaleString()}/月
+
+
+
+ ))}
+
+ )}
+
+
+ {compareRows.length >= 2 && (
+
+
+
行程内城市对比
+
+
+
+
+
+ | 城市 |
+ 月费 |
+ 网速 |
+ 气候 |
+ 评分 |
+ 社区 |
+
+
+
+ {compareRows.map(({ dest, scores }) => (
+
+ | {dest.emoji} {dest.name} |
+ ¥{dest.cost.toLocaleString()} |
+ {Math.min(scores.speed, 100)} |
+ {Math.min(scores.climate, 100)} |
+ {Math.min(scores.rating, 100)} |
+ {scores.community} |
+
+ ))}
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx
index 8f19e3d..c572a32 100644
--- a/frontend/src/components/Navbar.tsx
+++ b/frontend/src/components/Navbar.tsx
@@ -8,7 +8,7 @@ import { useAuth } from "@/lib/auth";
const LINKS = [
{ href: "/#map", label: "🗺️ 地图", section: "map" },
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" },
- { href: "/#trip", label: "🗓️ 行程", section: "trip" },
+ { href: "/plan", label: "🗓️ 计划", section: "plan" },
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" },
{ href: "/tools", label: "🛠️ 工具箱", section: "tools" },
{ href: "/#visa", label: "📋 签证", section: "visa" },
@@ -86,8 +86,8 @@ export default function Navbar() {
{LINKS.map((l) => (
setMenuOpen(false)}>{l.label}
diff --git a/frontend/src/components/TripPlanner.tsx b/frontend/src/components/TripPlanner.tsx
index 4534f0b..9cae7d8 100644
--- a/frontend/src/components/TripPlanner.tsx
+++ b/frontend/src/components/TripPlanner.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import Link from "next/link";
import { useToast } from "@/lib/toast";
import { loadTrip, saveTrip } from "@/lib/tripStorage";
import type { Destination, TripItem } from "@/lib/types";
@@ -41,7 +42,7 @@ export default function TripPlanner({ destinations }: Props) {
if (!dest || trip.some((t) => t.slug === dest.slug)) return;
save([...trip, {
slug: dest.slug, name: dest.name, country: dest.country,
- emoji: dest.emoji, cost: dest.cost, months,
+ emoji: dest.emoji, cost: dest.cost, months, note: "",
}]);
setSelected("");
setMonths(1);
@@ -56,7 +57,7 @@ export default function TripPlanner({ destinations }: Props) {
const shareTrip = async () => {
const encoded = btoa(JSON.stringify(trip));
- const url = `${window.location.origin}/?trip=${encoded}#trip`;
+ const url = `${window.location.origin}/plan?trip=${encoded}`;
await navigator.clipboard.writeText(url);
toast("分享链接已复制!发送给好友即可导入行程 🔗");
};
@@ -97,7 +98,10 @@ export default function TripPlanner({ destinations }: Props) {
🗓️ TRIP PLANNER
行程规划器
-
规划你的环球旅居路线,自动计算总预算
+
快速搭路线看预算;完整时间轴、签证提醒与出发清单请用计划中心
+
+ 打开旅居计划中心 →
+
diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx
index 0733cad..68ba635 100644
--- a/frontend/src/components/WorldMap.tsx
+++ b/frontend/src/components/WorldMap.tsx
@@ -66,7 +66,7 @@ export default function WorldMap({ destinations }: Props) {
查看详情 →
- 🗓️ 加入行程
+ 🗓️ 加入计划
) : (
diff --git a/frontend/src/lib/planMeta.ts b/frontend/src/lib/planMeta.ts
new file mode 100644
index 0000000..2e45dba
--- /dev/null
+++ b/frontend/src/lib/planMeta.ts
@@ -0,0 +1,125 @@
+const META_KEYS = ["nomadro-plan-meta", "nomadflow-plan-meta"] as const;
+const PRIMARY = META_KEYS[0];
+
+export interface PlanMeta {
+ title: string;
+ /** YYYY-MM,用于推算各站大致起止月 */
+ startMonth: string;
+ /** 用户月预算上限(¥),0 表示未设置 */
+ monthlyBudget: number;
+ checklist: Record;
+}
+
+export interface ChecklistItem {
+ id: string;
+ category: string;
+ label: string;
+ hint: string;
+}
+
+export const MOVE_CHECKLIST: ChecklistItem[] = [
+ { id: "passport", category: "证件签证", label: "护照有效期 ≥ 6 个月", hint: "部分国家按落地日算" },
+ { id: "visa-research", category: "证件签证", label: "确认入境方式与停留上限", hint: "免签 / 落地签 / DNV" },
+ { id: "insurance", category: "证件签证", label: "国际医疗保险已生效", hint: "含急诊与转运" },
+ { id: "flights", category: "行程落地", label: "首段机票 / 大巴已订", hint: "留弹性改签空间" },
+ { id: "housing", category: "行程落地", label: "首月住宿已确认", hint: "押金与退订政策看清" },
+ { id: "sim", category: "行程落地", label: "落地 SIM / eSIM 方案就绪", hint: "机场柜台或提前激活" },
+ { id: "bank-notify", category: "金钱安全", label: "告知银行境外消费", hint: "避免风控锁卡" },
+ { id: "cash-buffer", category: "金钱安全", label: "现金缓冲 ≥ 2 周开销", hint: "卡挂失时救急" },
+ { id: "backup-docs", category: "金钱安全", label: "护照/签证云备份", hint: "加密网盘 + 离线照片" },
+ { id: "client-notice", category: "远程工作", label: "客户已知时区与响应窗口", hint: "写清 overlap 时段" },
+ { id: "vpn-tools", category: "远程工作", label: "VPN / 2FA / 办公软件可用", hint: "出发前测一遍" },
+ { id: "emergency", category: "远程工作", label: "紧急联系人与领馆信息", hint: "存在手机与纸质" },
+];
+
+export const COUNTRY_STAY: Record = {
+ Thailand: { days: 60, note: "免签常见约 60 天;超期需延期或出境" },
+ Indonesia: { days: 30, note: "落地签常见 30 天,可按规定延期" },
+ Portugal: { days: 90, note: "申根 90/180 天累计,勿只看单次" },
+ Spain: { days: 90, note: "申根 90/180 天累计" },
+ Japan: { days: 90, note: "免签通常最长约 90 天" },
+ Mexico: { days: 180, note: "入境许可常见最长约 180 天" },
+ Vietnam: { days: 45, note: "免签天数常变,出发前务必核实" },
+ Malaysia: { days: 90, note: "多数国籍免签约 90 天" },
+ "South Korea": { days: 90, note: "免签常见约 90 天" },
+ "United States": { days: 90, note: "ESTA 等多为 90 天,不可远程打工身份" },
+ Colombia: { days: 90, note: "常见 90 天,可延长至约 180 天" },
+ Georgia: { days: 365, note: "多数国籍免签最长约 1 年" },
+ "United Arab Emirates": { days: 30, note: "落地签常见短签,远程身份另议" },
+ Germany: { days: 90, note: "申根 90/180;长期需签证" },
+ France: { days: 90, note: "申根 90/180;长期需签证" },
+ Italy: { days: 90, note: "申根 90/180;长期需签证" },
+ Croatia: { days: 90, note: "申根相关规则以出发前官方为准" },
+ Hungary: { days: 90, note: "申根 90/180" },
+ Czechia: { days: 90, note: "申根 90/180" },
+ "Czech Republic": { days: 90, note: "申根 90/180" },
+ Taiwan: { days: 90, note: "免签政策因护照而异" },
+ Singapore: { days: 30, note: "停留偏短,适合中转或短居" },
+ Philippines: { days: 30, note: "常见 30 天,可延期" },
+ Cambodia: { days: 30, note: "落地签常见 30 天" },
+ "Costa Rica": { days: 90, note: "常见 90 天旅游停留" },
+ Argentina: { days: 90, note: "常见 90 天" },
+ Brazil: { days: 90, note: "常见 90 天" },
+ Turkey: { days: 90, note: "常见 90 天,政策因护照而异" },
+ India: { days: 30, note: "电子签常见短签,远程工作另核" },
+ Nepal: { days: 30, note: "落地签常见,按档购买天数" },
+};
+
+const DEFAULT_META: PlanMeta = {
+ title: "我的旅居计划",
+ startMonth: "",
+ monthlyBudget: 0,
+ checklist: {},
+};
+
+export function loadPlanMeta(): PlanMeta {
+ if (typeof window === "undefined") return { ...DEFAULT_META, checklist: {} };
+ for (const key of META_KEYS) {
+ const raw = localStorage.getItem(key);
+ if (!raw) continue;
+ try {
+ const data = JSON.parse(raw) as Partial;
+ const meta: PlanMeta = {
+ title: typeof data.title === "string" && data.title.trim() ? data.title : DEFAULT_META.title,
+ startMonth: typeof data.startMonth === "string" ? data.startMonth : "",
+ monthlyBudget: typeof data.monthlyBudget === "number" ? data.monthlyBudget : 0,
+ checklist: data.checklist && typeof data.checklist === "object" ? data.checklist : {},
+ };
+ if (key !== PRIMARY) {
+ localStorage.setItem(PRIMARY, JSON.stringify(meta));
+ localStorage.removeItem(key);
+ }
+ return meta;
+ } catch { /* ignore */ }
+ }
+ return { ...DEFAULT_META, checklist: {} };
+}
+
+export function savePlanMeta(meta: PlanMeta) {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(PRIMARY, JSON.stringify(meta));
+ localStorage.removeItem("nomadflow-plan-meta");
+}
+
+/** 由出发月 + 各站月数推算大致区间文案 */
+export function rangeLabel(startMonth: string, offsetMonths: number, durationMonths: number): string {
+ if (!/^\d{4}-\d{2}$/.test(startMonth)) return "";
+ const [y, m] = startMonth.split("-").map(Number);
+ const start = new Date(y, m - 1 + offsetMonths, 1);
+ const end = new Date(y, m - 1 + offsetMonths + durationMonths, 0);
+ const fmt = (d: Date) => `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}`;
+ return `${fmt(start)} – ${fmt(end)}`;
+}
+
+export function stayHint(country: string, months: number): { text: string; overLimit: boolean } | null {
+ const info = COUNTRY_STAY[country];
+ if (!info) return null;
+ const plannedDays = Math.round(months * 30);
+ if (plannedDays > info.days) {
+ return {
+ overLimit: true,
+ text: `计划约 ${plannedDays} 天,常见停留上限约 ${info.days} 天。${info.note}`,
+ };
+ }
+ return { overLimit: false, text: `常见停留约 ${info.days} 天内 · ${info.note}` };
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index cc66774..52769bb 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -125,6 +125,8 @@ export interface TripItem {
emoji: string;
cost: number;
months: number;
+ /** 该站备注:住房意向、签证节点等 */
+ note?: string;
}
export interface ChartData {