Add /plan move hub: timeline, budget, visa stay hints, and readiness checklist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 02:43:20 -05:00
parent d857d5b9b2
commit b6df330a18
14 changed files with 959 additions and 18 deletions

View File

@ -96,7 +96,7 @@ docker compose up -d
| 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 | | 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 |
| 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 | | 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 |
| 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 | | 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 |
| 🗓️ 行程规划 | 多城市规划,预算汇总,分享/导入链接 | | 🗓️ 行程规划 | 首页快速规划;完整能力见「旅居计划中心」 |
| 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 | | 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 |
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 | | 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 | | 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
@ -136,6 +136,7 @@ docker compose up -d
| 📝 城市笔记 | 按城本地记录避坑与灵感 | | 📝 城市笔记 | 按城本地记录避坑与灵感 |
| ⏱️ 专注时钟 | 番茄钟深度工作计时 | | ⏱️ 专注时钟 | 番茄钟深度工作计时 |
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 | | 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 |
| 📜 更新日志 | `/changelog` 迭代记录 | | 📜 更新日志 | `/changelog` 迭代记录 |
| 🏧 取现避坑 | ATM / DCC / 换汇建议 | | 🏧 取现避坑 | ATM / DCC / 换汇建议 |
| 🥗 饮食饮水 | 各城饮水与街头饮食注意 | | 🥗 饮食饮水 | 各城饮水与街头饮食注意 |
@ -193,7 +194,9 @@ docker compose up -d
| `/` | 首页(全部板块) | | `/` | 首页(全部板块) |
| `/login` | 登录 / 注册 | | `/login` | 登录 / 注册 |
| `/profile` | 用户中心 | | `/profile` | 用户中心 |
| `/destinations/[slug]` | 目的地详情(城市画像、加入行程) | | `/destinations/[slug]` | 目的地详情(城市画像、加入计划) |
| `/plan` | 旅居计划中心(时间轴 / 预算 / 清单 / 分享) |
| `/tools` | 工具箱聚合 |
| `/blog/[slug]` | 博客详情(阅读进度) | | `/blog/[slug]` | 博客详情(阅读进度) |
## 环境变量 ## 环境变量

View File

@ -8,6 +8,15 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-08-29",
tag: "核心功能",
items: [
"全新「旅居计划中心」/plan:多城时间轴、出发月推算、站内备注、停留签证提醒",
"月预算上限对照、出发就绪清单、行程内城市对比、分享/导出 Markdown",
"导航与目的地「加入计划」、个人中心、全局搜索统一指向计划中心",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "大更新", tag: "大更新",

View File

@ -8680,3 +8680,320 @@ img { max-width: 100%; display: block; }
.kit-row, .deposit-top, .deposit-grid, .kit-grid-3 { grid-template-columns: 1fr; } .kit-row, .deposit-top, .deposit-grid, .kit-grid-3 { grid-template-columns: 1fr; }
.kit-stat { text-align: left; } .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; }
}

View File

@ -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 (
<SiteShell showFooter>
<MovePlanClient destinations={destinations} />
</SiteShell>
);
}

View File

@ -144,7 +144,7 @@ export default function ProfilePage() {
<div className="profile-empty"> <div className="profile-empty">
<span style={{ fontSize: "2.5rem" }}>🗺️</span> <span style={{ fontSize: "2.5rem" }}>🗺️</span>
<p>还没有规划行程</p> <p>还没有规划行程</p>
<Link href="/#trip" className="btn btn-primary">去规划 →</Link> <Link href="/plan" className="btn btn-primary">去规划 →</Link>
</div> </div>
) : ( ) : (
<div className="profile-trip-list"> <div className="profile-trip-list">
@ -162,7 +162,7 @@ export default function ProfilePage() {
总计 <strong>{trip.reduce((s, t) => s + t.months, 0)} 个月</strong> · 总计 <strong>{trip.reduce((s, t) => s + t.months, 0)} 个月</strong> ·
<strong className="gradient-text"> ¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()}</strong> <strong className="gradient-text"> ¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()}</strong>
</div> </div>
<Link href="/#trip" className="btn btn-ghost">编辑行程 →</Link> <Link href="/plan" className="btn btn-ghost">打开旅居计划中心 →</Link>
</div> </div>
)} )}
</section> </section>
@ -171,10 +171,10 @@ export default function ProfilePage() {
<h2>🚀 快捷入口</h2> <h2>🚀 快捷入口</h2>
<div className="profile-quick-grid"> <div className="profile-quick-grid">
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link> <Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
<Link href="/plan" className="quick-card">🗓️ 旅居计划</Link>
<Link href="/#visa" className="quick-card">📋 签证指南</Link> <Link href="/#visa" className="quick-card">📋 签证指南</Link>
<Link href="/#coworking" className="quick-card">💻 联合办公</Link> <Link href="/#coworking" className="quick-card">💻 联合办公</Link>
<Link href="/#blog" className="quick-card">📝 阅读博客</Link> <Link href="/#blog" className="quick-card">📝 阅读博客</Link>
<Link href="/#trip" className="quick-card">🗓️ 行程规划</Link>
<Link href="/#nomad-score" className="quick-card">📊 就绪度测评</Link> <Link href="/#nomad-score" className="quick-card">📊 就绪度测评</Link>
</div> </div>
</section> </section>

View File

@ -42,10 +42,10 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
toast("该城市已在行程中", "info"); toast("该城市已在行程中", "info");
return; 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); saveTrip(trip);
setAdded(true); setAdded(true);
toast(`${dest.emoji} ${dest.name} 已加入行程 🗓️`); toast(`${dest.emoji} ${dest.name} 已加入旅居计划`);
}; };
const share = async () => { const share = async () => {
@ -66,12 +66,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
return ( return (
<> <>
<div className="dest-detail-actions"> <div className="dest-detail-actions">
<button className={`btn ${added ? "btn-ghost" : "btn-primary"}`} onClick={addToTrip} disabled={added}> {added ? (
{added ? "✓ 已在行程中" : "🗓️ 加入行程"} <Link href="/plan" className="btn btn-primary">✓ 已在计划中 · 打开 →</Link>
</button> ) : (
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
)}
<button className="btn btn-ghost" onClick={share}>📤 分享</button> <button className="btn btn-ghost" onClick={share}>📤 分享</button>
<Link href="/plan" className="btn btn-ghost">🗓️ 计划中心</Link>
<Link href={`/#calculator`} className="btn btn-ghost">🧮 算费用</Link> <Link href={`/#calculator`} className="btn btn-ghost">🧮 算费用</Link>
<Link href={`/#timezone`} className="btn btn-ghost">🕐 看时区</Link>
</div> </div>
<div className="dest-detail-extras"> <div className="dest-detail-extras">

View File

@ -16,6 +16,7 @@ const TYPE_LABELS: Record<string, string> = {
}; };
const PAGE_RESULTS: SearchResult[] = [ const PAGE_RESULTS: SearchResult[] = [
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" }, { type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" }, { type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" }, { type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },

View File

@ -11,6 +11,7 @@ const SHORTCUTS = [
{ keys: ["M"], label: "智能匹配", icon: "🎯" }, { keys: ["M"], label: "智能匹配", icon: "🎯" },
{ keys: ["G"], label: "命运转盘", icon: "🎲" }, { keys: ["G"], label: "命运转盘", icon: "🎲" },
{ keys: ["T"], label: "打开工具箱", icon: "🛠️" }, { keys: ["T"], label: "打开工具箱", icon: "🛠️" },
{ keys: ["P"], label: "旅居计划中心", icon: "🗓️" },
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" }, { keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" }, { keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" }, { keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
@ -41,6 +42,10 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
e.preventDefault(); e.preventDefault();
window.location.href = "/tools"; 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); if (e.key === "Escape" && open) setOpen(false);
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);

View File

@ -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<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta>(() => ({
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<TripItem>());
}
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<string, typeof MOVE_CHECKLIST>();
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<TripItem>) =>
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 (
<div className="plan-page">
<div className="container">
<p className="plan-loading">加载计划中…</p>
</div>
</div>
);
}
return (
<div className="plan-page">
<div className="container">
<nav className="detail-nav">
<Link href="/">← 返回首页</Link>
<Link href="/#destinations">浏览目的地</Link>
</nav>
<header className="plan-hero">
<span className="section-tag">🗓️ MOVE PLAN</span>
<input
className="plan-title-input"
value={meta.title}
onChange={(e) => persistMeta({ ...meta, title: e.target.value || "我的旅居计划" })}
aria-label="计划标题"
/>
<p>把多城路线、预算、签证提醒和出发清单放在一页——真正能拿去执行的旅居计划。</p>
<div className="plan-meta-row">
<label className="plan-meta-field">
<span>预计出发月</span>
<input
type="month"
value={meta.startMonth}
onChange={(e) => persistMeta({ ...meta, startMonth: e.target.value })}
/>
</label>
<label className="plan-meta-field">
<span>月预算上限(¥)</span>
<input
type="number"
min={0}
step={500}
placeholder="如 8000"
value={meta.monthlyBudget || ""}
onChange={(e) => persistMeta({ ...meta, monthlyBudget: Math.max(0, +e.target.value || 0) })}
/>
</label>
</div>
</header>
<div className="plan-stats">
<div className="plan-stat">
<span>城市</span>
<strong>{trip.length}</strong>
</div>
<div className="plan-stat">
<span>总时长</span>
<strong>{totalMonths} 月</strong>
</div>
<div className="plan-stat">
<span>总预算</span>
<strong className="gradient-text">¥{totalCost.toLocaleString()}</strong>
</div>
<div className="plan-stat">
<span>均月开销</span>
<strong className={!budgetOk ? "plan-warn-text" : undefined}>
¥{avgMonth.toLocaleString()}
</strong>
</div>
<div className="plan-stat">
<span>出发就绪</span>
<strong>{readiness}%</strong>
</div>
</div>
{!budgetOk && (
<div className="plan-alert" role="status">
均月开销 ¥{avgMonth.toLocaleString()} 已超过你的月预算上限 ¥{meta.monthlyBudget.toLocaleString()}。
可缩短高消费城市停留,或调高预算。
</div>
)}
<div className="plan-layout">
<div className="plan-main">
<section className="plan-panel">
<div className="plan-panel-head">
<h2>路线时间轴</h2>
<div className="plan-actions">
<button type="button" className="btn btn-ghost btn-sm" onClick={copyPlain} disabled={!trip.length}>复制</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={exportTrip} disabled={!trip.length}>导出 MD</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={shareTrip} disabled={!trip.length}>分享</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { persistTrip([]); toast("已清空行程"); }} disabled={!trip.length}>清空</button>
</div>
</div>
{trip.length === 0 ? (
<div className="plan-empty">
<span className="plan-empty-icon">🗺️</span>
<p>还没有城市。从目的地加入,或在下方添加第一站。</p>
<Link href="/#destinations" className="btn btn-primary">去选目的地 →</Link>
</div>
) : (
<ol className="plan-timeline">
{timeline.map((t, i) => (
<li key={t.slug} className="plan-stop">
<div className="plan-stop-rail">
<span className="plan-stop-dot">{t.emoji}</span>
{i < timeline.length - 1 && <span className="plan-stop-line" />}
</div>
<div className="plan-stop-body">
<div className="plan-stop-top">
<div>
<strong>
<Link href={`/destinations/${t.slug}`}>{t.name}</Link>
<span className="plan-stop-country">, {t.country}</span>
</strong>
{t.range && <span className="plan-stop-range">{t.range}</span>}
</div>
<div className="plan-stop-tools">
<button type="button" aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}>↑</button>
<button type="button" aria-label="下移" disabled={i === trip.length - 1} onClick={() => move(i, 1)}>↓</button>
<button type="button" className="plan-remove" aria-label="移除" onClick={() => removeCity(t.slug)}>✕</button>
</div>
</div>
<div className="plan-stop-controls">
<label>
停留
<input
type="number"
min={1}
max={24}
value={t.months}
onChange={(e) => updateItem(t.slug, { months: Math.max(1, Math.min(24, +e.target.value || 1)) })}
/>
月
</label>
<span className="plan-stop-cost">¥{(t.cost * t.months).toLocaleString()}</span>
<span className="plan-stop-unit">约 ¥{t.cost.toLocaleString()}/月</span>
</div>
<textarea
className="plan-note"
rows={2}
placeholder="备注:住房、签证节点、必办事项…"
value={t.note || ""}
onChange={(e) => updateItem(t.slug, { note: e.target.value })}
/>
{t.visa && (
<p className={`plan-visa${t.visa.overLimit ? " warn" : ""}`}>
🛂 {t.visa.text}
</p>
)}
</div>
</li>
))}
</ol>
)}
</section>
{compareRows.length >= 2 && (
<section className="plan-panel">
<div className="plan-panel-head">
<h2>行程内城市对比</h2>
</div>
<div className="plan-compare-wrap">
<table className="plan-compare">
<thead>
<tr>
<th>城市</th>
<th>月费</th>
<th>网速</th>
<th>气候</th>
<th>评分</th>
<th>社区</th>
</tr>
</thead>
<tbody>
{compareRows.map(({ dest, scores }) => (
<tr key={dest.slug}>
<td>{dest.emoji} {dest.name}</td>
<td>¥{dest.cost.toLocaleString()}</td>
<td>{Math.min(scores.speed, 100)}</td>
<td>{Math.min(scores.climate, 100)}</td>
<td>{Math.min(scores.rating, 100)}</td>
<td>{scores.community}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
<aside className="plan-side">
<section className="plan-panel">
<h2>添加城市</h2>
<div className="calc-field">
<label>目的地</label>
<select value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value="">请选择…</option>
{destinations
.filter((d) => !trip.some((t) => t.slug === d.slug))
.map((d) => (
<option key={d.slug} value={d.slug}>
{d.emoji} {d.name}, {d.country} · ¥{d.cost.toLocaleString()}
</option>
))}
</select>
</div>
<div className="calc-field">
<label>停留月数:{months}</label>
<input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} />
</div>
<button type="button" className="btn btn-primary" disabled={!selected} onClick={addCity}>
加入时间轴
</button>
</section>
<section className="plan-panel">
<div className="plan-panel-head">
<h2>出发就绪清单</h2>
<span className="plan-ready-pill">{checkedCount}/{MOVE_CHECKLIST.length}</span>
</div>
<div className="plan-ready-bar" aria-hidden>
<div style={{ width: `${readiness}%` }} />
</div>
{checklistByCat.map(([cat, items]) => (
<div key={cat} className="plan-check-group">
<h3>{cat}</h3>
<ul>
{items.map((item) => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={!!meta.checklist[item.id]}
onChange={() => toggleCheck(item.id)}
/>
<span>
<strong>{item.label}</strong>
<small>{item.hint}</small>
</span>
</label>
</li>
))}
</ul>
</div>
))}
</section>
</aside>
</div>
</div>
</div>
);
}

View File

@ -8,7 +8,7 @@ import { useAuth } from "@/lib/auth";
const LINKS = [ const LINKS = [
{ href: "/#map", label: "🗺️ 地图", section: "map" }, { href: "/#map", label: "🗺️ 地图", section: "map" },
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" }, { href: "/#destinations", label: "🌍 目的地", section: "destinations" },
{ href: "/#trip", label: "🗓️ 行程", section: "trip" }, { href: "/plan", label: "🗓️ 计划", section: "plan" },
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" }, { href: "/#calculator", label: "🧮 计算器", section: "calculator" },
{ href: "/tools", label: "🛠️ 工具箱", section: "tools" }, { href: "/tools", label: "🛠️ 工具箱", section: "tools" },
{ href: "/#visa", label: "📋 签证", section: "visa" }, { href: "/#visa", label: "📋 签证", section: "visa" },
@ -86,8 +86,8 @@ export default function Navbar() {
{LINKS.map((l) => ( {LINKS.map((l) => (
<Link key={l.section} href={l.href} data-section={l.section} <Link key={l.section} href={l.href} data-section={l.section}
className={ className={
l.href === "/tools" l.href === "/tools" || l.href === "/plan"
? (pathname === "/tools" ? "active" : "") ? (pathname === l.href ? "active" : "")
: (isHome && active === l.section ? "active" : "") : (isHome && active === l.section ? "active" : "")
} }
onClick={() => setMenuOpen(false)}>{l.label}</Link> onClick={() => setMenuOpen(false)}>{l.label}</Link>

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { loadTrip, saveTrip } from "@/lib/tripStorage"; import { loadTrip, saveTrip } from "@/lib/tripStorage";
import type { Destination, TripItem } from "@/lib/types"; 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; if (!dest || trip.some((t) => t.slug === dest.slug)) return;
save([...trip, { save([...trip, {
slug: dest.slug, name: dest.name, country: dest.country, 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(""); setSelected("");
setMonths(1); setMonths(1);
@ -56,7 +57,7 @@ export default function TripPlanner({ destinations }: Props) {
const shareTrip = async () => { const shareTrip = async () => {
const encoded = btoa(JSON.stringify(trip)); 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); await navigator.clipboard.writeText(url);
toast("分享链接已复制!发送给好友即可导入行程 🔗"); toast("分享链接已复制!发送给好友即可导入行程 🔗");
}; };
@ -97,7 +98,10 @@ export default function TripPlanner({ destinations }: Props) {
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">🗓️ TRIP PLANNER</span> <span className="section-tag">🗓️ TRIP PLANNER</span>
<h2>行程规划器</h2> <h2>行程规划器</h2>
<p>规划你的环球旅居路线,自动计算总预算</p> <p>快速搭路线看预算;完整时间轴、签证提醒与出发清单请用计划中心</p>
<Link href="/plan" className="btn btn-primary" style={{ marginTop: 12 }}>
打开旅居计划中心 →
</Link>
</div> </div>
<div className="trip-wrapper reveal"> <div className="trip-wrapper reveal">
<div className="trip-form"> <div className="trip-form">

View File

@ -66,7 +66,7 @@ export default function WorldMap({ destinations }: Props) {
<Link href={`/destinations/${selected.slug}`} className="btn btn-primary btn-sm"> <Link href={`/destinations/${selected.slug}`} className="btn btn-primary btn-sm">
查看详情 → 查看详情 →
</Link> </Link>
<Link href={`/#trip`} className="btn btn-ghost btn-sm">🗓️ 加入行程</Link> <Link href="/plan" className="btn btn-ghost btn-sm">🗓️ 加入计划</Link>
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -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<string, boolean>;
}
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<string, { days: number; note: string }> = {
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<PlanMeta>;
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}` };
}

View File

@ -125,6 +125,8 @@ export interface TripItem {
emoji: string; emoji: string;
cost: number; cost: number;
months: number; months: number;
/** 该站备注:住房意向、签证节点等 */
note?: string;
} }
export interface ChartData { export interface ChartData {