))}
@@ -384,6 +466,44 @@ export default function MovePlanClient({ destinations }: Props) {
)}
+ {(tripVisas.length > 0 || (trip.length > 0 && missingVisaCountries.length > 0)) && (
+
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",