From 9a6474a69a4123084998124d63a39f7e530ebaad Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 29 Aug 2026 03:37:52 -0500 Subject: [PATCH] Wire plan to real visas (CN stay keys) and add ICS calendar export. Co-authored-by: Cursor --- README.md | 2 +- frontend/src/app/changelog/page.tsx | 9 ++ frontend/src/app/globals.css | 64 ++++++++++ frontend/src/app/plan/page.tsx | 15 ++- frontend/src/components/MovePlanClient.tsx | 130 ++++++++++++++++++++- frontend/src/lib/planIcs.ts | 76 ++++++++++++ frontend/src/lib/planMeta.ts | 95 +++++++++------ scripts/deploy_direct_sync.py | 1 + 8 files changed, 343 insertions(+), 49 deletions(-) create mode 100644 frontend/src/lib/planIcs.ts diff --git a/README.md b/README.md index 0180c57..283e764 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ docker compose up -d | 📝 城市笔记 | 按城本地记录避坑与灵感 | | ⏱️ 专注时钟 | 番茄钟深度工作计时 | | 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 | -| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出;登录后云端同步 | +| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证方案、超期提醒、就绪清单、MD/ICS 导出;登录同步 | | ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 | | 📜 更新日志 | `/changelog` 迭代记录 | | 🏧 取现避坑 | ATM / DCC / 换汇建议 | diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index d935d4b..6c514b1 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: [ + "修复停留签证提醒(中英文国家名对齐),超期站点醒目提示", + "计划页接入签证目录:按行程国家展示游民签证方案与收入/审批信息", + "导出日历(.ics):设出发月后可导入手机 / Google Calendar", + ], + }, { date: "2026-08-29", tag: "账号同步", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a4412ec..bf40383 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -9167,3 +9167,67 @@ img { max-width: 100%; display: block; } flex-direction: column; gap: 6px; } + +/* ===== Plan visas + ICS ===== */ +.plan-alert-visa { + border-color: color-mix(in srgb, #FFE66D 45%, transparent); + background: color-mix(in srgb, #FFE66D 10%, transparent); +} +.plan-stop-visas { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.plan-visa-chip { + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 999px; + border: var(--border-glass); + color: var(--text-secondary); + background: color-mix(in srgb, var(--accent-2) 8%, transparent); +} +.plan-visa-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; +} +.plan-visa-card { + padding: 14px; + border-radius: var(--radius-lg); + border: var(--border-glass); + background: color-mix(in srgb, var(--bg-card) 80%, transparent); +} +.plan-visa-card header { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 10px; +} +.plan-visa-card header span { font-size: 1.5rem; } +.plan-visa-card strong { display: block; font-size: 0.95rem; } +.plan-visa-card small { + color: var(--text-secondary); + font-size: 0.75rem; +} +.plan-visa-card ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.82rem; + color: var(--text-secondary); +} +.plan-visa-card p { + margin: 8px 0 0; + font-size: 0.8rem; + color: var(--accent-2); +} +.plan-visa-missing { + margin: 12px 0 0; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.5; +} diff --git a/frontend/src/app/plan/page.tsx b/frontend/src/app/plan/page.tsx index cdc7aec..c9d5bb0 100644 --- a/frontend/src/app/plan/page.tsx +++ b/frontend/src/app/plan/page.tsx @@ -1,27 +1,32 @@ import type { Metadata } from "next"; import { api } from "@/lib/api"; -import type { Destination } from "@/lib/types"; +import type { Destination, Visa } from "@/lib/types"; import MovePlanClient from "@/components/MovePlanClient"; import SiteShell from "@/components/SiteShell"; export const metadata: Metadata = { title: "旅居计划中心 · nomadro", - description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单——一页做完旅居规划", + description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单、日历导出——一页做完旅居规划", }; export const revalidate = 60; export default async function PlanPage() { let destinations: Destination[] = []; + let visas: Visa[] = []; try { - destinations = await api.getDestinations(); + [destinations, visas] = await Promise.all([api.getDestinations(), api.getVisas()]); } catch { - destinations = []; + try { + destinations = await api.getDestinations(); + } catch { + destinations = []; + } } return ( - + ); } diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx index b5fde31..df4ca9b 100644 --- a/frontend/src/components/MovePlanClient.tsx +++ b/frontend/src/components/MovePlanClient.tsx @@ -14,10 +14,12 @@ import { type PlanMeta, } from "@/lib/planMeta"; import { PLAN_SYNC_EVENT, pushPlanNow } from "@/lib/planSync"; -import type { Destination, TripItem } from "@/lib/types"; +import { buildPlanIcs, downloadPlanIcs } from "@/lib/planIcs"; +import type { Destination, TripItem, Visa } from "@/lib/types"; interface Props { destinations: Destination[]; + visas?: Visa[]; } function scoreDest(d: Destination) { @@ -31,7 +33,7 @@ function scoreDest(d: Destination) { }; } -export default function MovePlanClient({ destinations }: Props) { +export default function MovePlanClient({ destinations, visas = [] }: Props) { const { toast } = useToast(); const { token, user, planSyncStatus } = useAuth(); const [trip, setTrip] = useState([]); @@ -97,11 +99,32 @@ export default function MovePlanClient({ destinations }: Props) { 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) }; + const row = { + ...t, + range: label, + offset, + visa: stayHint(t.country, t.months), + visaOptions: visas.filter((v) => v.country === t.country), + }; offset += t.months; return row; }); - }, [trip, meta.startMonth]); + }, [trip, meta.startMonth, visas]); + + const overLimitStops = useMemo( + () => timeline.filter((t) => t.visa?.overLimit), + [timeline] + ); + + const tripVisas = useMemo(() => { + const countries = new Set(trip.map((t) => t.country)); + return visas.filter((v) => countries.has(v.country)); + }, [trip, visas]); + + const missingVisaCountries = useMemo(() => { + const have = new Set(tripVisas.map((v) => v.country)); + return [...new Set(trip.map((t) => t.country))].filter((c) => !have.has(c)); + }, [trip, tripVisas]); const compareRows = useMemo(() => { return trip @@ -175,7 +198,8 @@ export default function MovePlanClient({ destinations }: Props) { ...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}`; + const visaLine = t.visa ? `\n 签证:${t.visa.text}` : ""; + return `${i + 1}. ${t.emoji} **${t.name}, ${t.country}** — ${t.months} 个月 · ¥${(t.cost * t.months).toLocaleString()}${range}${note}${visaLine}`; }), "", `> 总计 **${totalMonths} 个月** · **¥${totalCost.toLocaleString()}** · 均月 ¥${avgMonth.toLocaleString()}`, @@ -193,6 +217,33 @@ export default function MovePlanClient({ destinations }: Props) { toast("已下载 Markdown 计划"); }; + const exportIcs = () => { + if (!meta.startMonth) { + toast("请先设置预计出发月,再导出日历", "info"); + return; + } + const ics = buildPlanIcs({ + title: meta.title, + startMonth: meta.startMonth, + stops: timeline.map((t) => ({ + slug: t.slug, + name: t.name, + country: t.country, + emoji: t.emoji, + cost: t.cost, + months: t.months, + note: t.note, + offset: t.offset, + })), + }); + if (!ics) { + toast("无法生成日历,请检查行程", "error"); + return; + } + downloadPlanIcs(`nomadro-plan-${meta.startMonth}.ics`, ics); + toast("已下载日历文件,可导入手机/Google Calendar"); + }; + 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})` : ""}`) @@ -308,6 +359,27 @@ export default function MovePlanClient({ destinations }: Props) { )} + {overLimitStops.length > 0 && ( +
+ {overLimitStops.length} 站停留可能超过常见免签/短签上限 + ({overLimitStops.map((s) => s.name).join("、")})。 + 请查看下方长期签证方案,或缩短停留。 + {!meta.checklist["visa-research"] && ( + + )} +
+ )} +
@@ -316,6 +388,7 @@ export default function MovePlanClient({ destinations }: Props) {
+
@@ -377,6 +450,15 @@ export default function MovePlanClient({ destinations }: Props) { 🛂 {t.visa.text}

)} + {t.visaOptions.length > 0 && ( +
+ {t.visaOptions.map((v) => ( + + {v.flag} {v.name} · {v.duration} + + ))} +
+ )}
))} @@ -384,6 +466,44 @@ export default function MovePlanClient({ destinations }: Props) { )} + {(tripVisas.length > 0 || (trip.length > 0 && missingVisaCountries.length > 0)) && ( +
+
+

本行程签证方案

+ 签证指南 → +
+ {tripVisas.length > 0 ? ( +
+ {tripVisas.map((v) => ( +
+
+ {v.flag} +
+ {v.name} + {v.country} · {v.badge} +
+
+
    +
  • 时长 {v.duration}
  • +
  • 收入要求 {v.income_req}
  • +
  • 审批 {v.approval_time}
  • +
  • 难度 {v.difficulty_label}
  • +
+ {v.extra &&

{v.extra}

} +
+ ))} +
+ ) : ( +

当前行程国家暂无收录的游民签证条目,请对照停留提醒与官方政策。

+ )} + {missingVisaCountries.length > 0 && tripVisas.length > 0 && ( +

+ 暂无收录:{missingVisaCountries.join("、")} — 请以官方政策为准,并确认免签天数。 +

+ )} +
+ )} + {compareRows.length >= 2 && (
diff --git a/frontend/src/lib/planIcs.ts b/frontend/src/lib/planIcs.ts new file mode 100644 index 0000000..ed083a9 --- /dev/null +++ b/frontend/src/lib/planIcs.ts @@ -0,0 +1,76 @@ +import { rangeDates } from "@/lib/planMeta"; +import type { TripItem } from "@/lib/types"; + +function pad(n: number) { + return String(n).padStart(2, "0"); +} + +function formatDate(d: Date): string { + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; +} + +function formatStamp(d = new Date()): string { + return `${formatDate(d)}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}Z`; +} + +function escapeText(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); +} + +export interface IcsStop extends TripItem { + offset: number; +} + +/** Build an ICS calendar from plan stops (requires startMonth YYYY-MM). */ +export function buildPlanIcs(opts: { + title: string; + startMonth: string; + stops: IcsStop[]; +}): string | null { + if (!opts.startMonth || opts.stops.length === 0) return null; + const now = formatStamp(); + const lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//nomadro//Move Plan//CN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + `X-WR-CALNAME:${escapeText(opts.title || "nomadro 旅居计划")}`, + ]; + + opts.stops.forEach((stop, i) => { + const range = rangeDates(opts.startMonth, stop.offset, stop.months); + if (!range) return; + const summary = `${stop.emoji} ${stop.name}, ${stop.country}`; + const descParts = [ + `停留 ${stop.months} 个月`, + `约 ¥${(stop.cost * stop.months).toLocaleString()}`, + stop.note ? `备注:${stop.note}` : "", + "由 nomadro 旅居计划中心生成", + ].filter(Boolean); + lines.push( + "BEGIN:VEVENT", + `UID:nomadro-${stop.slug}-${i}@nomadro.com`, + `DTSTAMP:${now}`, + `DTSTART;VALUE=DATE:${formatDate(range.start)}`, + `DTEND;VALUE=DATE:${formatDate(range.endExclusive)}`, + `SUMMARY:${escapeText(summary)}`, + `DESCRIPTION:${escapeText(descParts.join(" · "))}`, + `LOCATION:${escapeText(`${stop.name}, ${stop.country}`)}`, + "END:VEVENT" + ); + }); + + lines.push("END:VCALENDAR"); + return lines.join("\r\n"); +} + +export function downloadPlanIcs(filename: string, ics: string) { + const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/lib/planMeta.ts b/frontend/src/lib/planMeta.ts index efa4413..f81b9a8 100644 --- a/frontend/src/lib/planMeta.ts +++ b/frontend/src/lib/planMeta.ts @@ -32,38 +32,45 @@ export const MOVE_CHECKLIST: ChecklistItem[] = [ { 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: "落地签常见,按档购买天数" }, -}; +export const COUNTRY_STAY: Record = (() => { + const entries: { keys: string[]; days: number; note: string }[] = [ + { keys: ["泰国", "Thailand"], days: 60, note: "免签常见约 60 天;超期需延期或出境" }, + { keys: ["印尼", "Indonesia"], days: 30, note: "落地签常见 30 天,可按规定延期" }, + { keys: ["葡萄牙", "Portugal"], days: 90, note: "申根 90/180 天累计,勿只看单次" }, + { keys: ["西班牙", "Spain"], days: 90, note: "申根 90/180 天累计" }, + { keys: ["日本", "Japan"], days: 90, note: "免签通常最长约 90 天" }, + { keys: ["墨西哥", "Mexico"], days: 180, note: "入境许可常见最长约 180 天" }, + { keys: ["越南", "Vietnam"], days: 45, note: "免签天数常变,出发前务必核实" }, + { keys: ["马来西亚", "Malaysia"], days: 90, note: "多数国籍免签约 90 天" }, + { keys: ["韩国", "South Korea", "Korea"], days: 90, note: "免签常见约 90 天" }, + { keys: ["美国", "United States", "USA"], days: 90, note: "ESTA 等多为 90 天,不可远程打工身份" }, + { keys: ["哥伦比亚", "Colombia"], days: 90, note: "常见 90 天,可延长至约 180 天" }, + { keys: ["格鲁吉亚", "Georgia"], days: 365, note: "多数国籍免签最长约 1 年" }, + { keys: ["阿联酋", "United Arab Emirates", "UAE"], days: 30, note: "落地签常见短签,远程身份另议" }, + { keys: ["德国", "Germany"], days: 90, note: "申根 90/180;长期需签证" }, + { keys: ["法国", "France"], days: 90, note: "申根 90/180;长期需签证" }, + { keys: ["意大利", "Italy"], days: 90, note: "申根 90/180;长期需签证" }, + { keys: ["克罗地亚", "Croatia"], days: 90, note: "申根相关规则以出发前官方为准" }, + { keys: ["匈牙利", "Hungary"], days: 90, note: "申根 90/180" }, + { keys: ["捷克", "Czechia", "Czech Republic"], days: 90, note: "申根 90/180" }, + { keys: ["台湾", "Taiwan"], days: 90, note: "免签政策因护照而异" }, + { keys: ["新加坡", "Singapore"], days: 30, note: "停留偏短,适合中转或短居" }, + { keys: ["菲律宾", "Philippines"], days: 30, note: "常见 30 天,可延期" }, + { keys: ["柬埔寨", "Cambodia"], days: 30, note: "落地签常见 30 天" }, + { keys: ["哥斯达黎加", "Costa Rica"], days: 90, note: "常见 90 天旅游停留" }, + { keys: ["阿根廷", "Argentina"], days: 90, note: "常见 90 天" }, + { keys: ["巴西", "Brazil"], days: 90, note: "常见 90 天" }, + { keys: ["土耳其", "Turkey"], days: 90, note: "常见 90 天,政策因护照而异" }, + { keys: ["印度", "India"], days: 30, note: "电子签常见短签,远程工作另核" }, + { keys: ["尼泊尔", "Nepal"], days: 30, note: "落地签常见,按档购买天数" }, + { keys: ["爱沙尼亚", "Estonia"], days: 90, note: "申根 90/180;长期可看 DNV" }, + ]; + const map: Record = {}; + for (const e of entries) { + for (const k of e.keys) map[k] = { days: e.days, note: e.note }; + } + return map; +})(); const DEFAULT_META: PlanMeta = { title: "我的旅居计划", @@ -110,12 +117,24 @@ export function savePlanMeta(meta: PlanMeta) { /** 由出发月 + 各站月数推算大致区间文案 */ export function rangeLabel(startMonth: string, offsetMonths: number, durationMonths: number): string { - if (!/^\d{4}-\d{2}$/.test(startMonth)) return ""; + const range = rangeDates(startMonth, offsetMonths, durationMonths); + if (!range) return ""; + const fmt = (d: Date) => `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}`; + return `${fmt(range.start)} – ${fmt(range.endInclusive)}`; +} + +/** 区间日期:start 为月初,endExclusive 为下一站月初(ICS DTEND 用) */ +export function rangeDates( + startMonth: string, + offsetMonths: number, + durationMonths: number +): { start: Date; endInclusive: Date; endExclusive: Date } | null { + if (!/^\d{4}-\d{2}$/.test(startMonth) || durationMonths < 1) return null; 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)}`; + const endExclusive = new Date(y, m - 1 + offsetMonths + durationMonths, 1); + const endInclusive = new Date(endExclusive.getTime() - 86400000); + return { start, endInclusive, endExclusive }; } export function stayHint(country: string, months: number): { text: string; overLimit: boolean } | null { @@ -125,8 +144,8 @@ export function stayHint(country: string, months: number): { text: string; overL if (plannedDays > info.days) { return { overLimit: true, - text: `计划约 ${plannedDays} 天,常见停留上限约 ${info.days} 天。${info.note}`, + text: `计划约 ${plannedDays} 天,常见免签/短签上限约 ${info.days} 天。${info.note}`, }; } - return { overLimit: false, text: `常见停留约 ${info.days} 天内 · ${info.note}` }; + return { overLimit: false, text: `常见短期停留约 ${info.days} 天内 · ${info.note}` }; } diff --git a/scripts/deploy_direct_sync.py b/scripts/deploy_direct_sync.py index dde042a..890c422 100644 --- a/scripts/deploy_direct_sync.py +++ b/scripts/deploy_direct_sync.py @@ -42,6 +42,7 @@ FILES = [ "frontend/src/lib/matcherPrefs.ts", "frontend/src/lib/tripStorage.ts", "frontend/src/lib/planSync.ts", + "frontend/src/lib/planIcs.ts", "frontend/src/lib/auth.tsx", "frontend/src/lib/api.ts", "backend/app/services/auth.py",