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 ( +
+
+ + +
+ 🗓️ MOVE PLAN + persistMeta({ ...meta, title: e.target.value || "我的旅居计划" })} + aria-label="计划标题" + /> +

把多城路线、预算、签证提醒和出发清单放在一页——真正能拿去执行的旅居计划。

+
+ + +
+
+ +
+
+ 城市 + {trip.length} +
+
+ 总时长 + {totalMonths} 月 +
+
+ 总预算 + ¥{totalCost.toLocaleString()} +
+
+ 均月开销 + + ¥{avgMonth.toLocaleString()} + +
+
+ 出发就绪 + {readiness}% +
+
+ + {!budgetOk && ( +
+ 均月开销 ¥{avgMonth.toLocaleString()} 已超过你的月预算上限 ¥{meta.monthlyBudget.toLocaleString()}。 + 可缩短高消费城市停留,或调高预算。 +
+ )} + +
+
+
+
+

路线时间轴

+
+ + + + +
+
+ + {trip.length === 0 ? ( +
+ 🗺️ +

还没有城市。从目的地加入,或在下方添加第一站。

+ 去选目的地 → +
+ ) : ( +
    + {timeline.map((t, i) => ( +
  1. +
    + {t.emoji} + {i < timeline.length - 1 && } +
    +
    +
    +
    + + {t.name} + , {t.country} + + {t.range && {t.range}} +
    +
    + + + +
    +
    +
    + + ¥{(t.cost * t.months).toLocaleString()} + 约 ¥{t.cost.toLocaleString()}/月 +
    +