Wire plan to real visas (CN stay keys) and add ICS calendar export.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 03:37:52 -05:00
parent a1b2408ed0
commit 9a6474a69a
8 changed files with 343 additions and 49 deletions

View File

@ -136,7 +136,7 @@ docker compose up -d
| 📝 城市笔记 | 按城本地记录避坑与灵感 | | 📝 城市笔记 | 按城本地记录避坑与灵感 |
| ⏱️ 专注时钟 | 番茄钟深度工作计时 | | ⏱️ 专注时钟 | 番茄钟深度工作计时 |
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 | | 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出;登录后云端同步 | | 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证方案、超期提醒、就绪清单、MD/ICS 导出;登录同步 |
| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 | | ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 |
| 📜 更新日志 | `/changelog` 迭代记录 | | 📜 更新日志 | `/changelog` 迭代记录 |
| 🏧 取现避坑 | ATM / DCC / 换汇建议 | | 🏧 取现避坑 | ATM / DCC / 换汇建议 |

View File

@ -8,6 +8,15 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-08-29",
tag: "计划深化",
items: [
"修复停留签证提醒(中英文国家名对齐),超期站点醒目提示",
"计划页接入签证目录:按行程国家展示游民签证方案与收入/审批信息",
"导出日历(.ics):设出发月后可导入手机 / Google Calendar",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "账号同步", tag: "账号同步",

View File

@ -9167,3 +9167,67 @@ img { max-width: 100%; display: block; }
flex-direction: column; flex-direction: column;
gap: 6px; 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;
}

View File

@ -1,27 +1,32 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Destination } from "@/lib/types"; import type { Destination, Visa } from "@/lib/types";
import MovePlanClient from "@/components/MovePlanClient"; import MovePlanClient from "@/components/MovePlanClient";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "旅居计划中心 · nomadro", title: "旅居计划中心 · nomadro",
description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单——一页做完旅居规划", description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单、日历导出——一页做完旅居规划",
}; };
export const revalidate = 60; export const revalidate = 60;
export default async function PlanPage() { export default async function PlanPage() {
let destinations: Destination[] = []; let destinations: Destination[] = [];
let visas: Visa[] = [];
try { try {
destinations = await api.getDestinations(); [destinations, visas] = await Promise.all([api.getDestinations(), api.getVisas()]);
} catch { } catch {
destinations = []; try {
destinations = await api.getDestinations();
} catch {
destinations = [];
}
} }
return ( return (
<SiteShell showFooter> <SiteShell showFooter>
<MovePlanClient destinations={destinations} /> <MovePlanClient destinations={destinations} visas={visas} />
</SiteShell> </SiteShell>
); );
} }

View File

@ -14,10 +14,12 @@ import {
type PlanMeta, type PlanMeta,
} from "@/lib/planMeta"; } from "@/lib/planMeta";
import { PLAN_SYNC_EVENT, pushPlanNow } from "@/lib/planSync"; 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 { interface Props {
destinations: Destination[]; destinations: Destination[];
visas?: Visa[];
} }
function scoreDest(d: Destination) { 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 { toast } = useToast();
const { token, user, planSyncStatus } = useAuth(); const { token, user, planSyncStatus } = useAuth();
const [trip, setTrip] = useState<TripItem[]>([]); const [trip, setTrip] = useState<TripItem[]>([]);
@ -97,11 +99,32 @@ export default function MovePlanClient({ destinations }: Props) {
let offset = 0; let offset = 0;
return trip.map((t) => { return trip.map((t) => {
const label = rangeLabel(meta.startMonth, offset, t.months); 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; offset += t.months;
return row; 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(() => { const compareRows = useMemo(() => {
return trip return trip
@ -175,7 +198,8 @@ export default function MovePlanClient({ destinations }: Props) {
...timeline.map((t, i) => { ...timeline.map((t, i) => {
const range = t.range ? `(${t.range})` : ""; const range = t.range ? `(${t.range})` : "";
const note = t.note ? `\n 备注:${t.note}` : ""; 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()}`, `> 总计 **${totalMonths} 个月** · **¥${totalCost.toLocaleString()}** · 均月 ¥${avgMonth.toLocaleString()}`,
@ -193,6 +217,33 @@ export default function MovePlanClient({ destinations }: Props) {
toast("已下载 Markdown 计划"); 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 copyPlain = async () => {
const text = timeline 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})` : ""}`) .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) {
</div> </div>
)} )}
{overLimitStops.length > 0 && (
<div className="plan-alert plan-alert-visa" role="status">
{overLimitStops.length} 站停留可能超过常见免签/短签上限
({overLimitStops.map((s) => s.name).join("、")})。
请查看下方长期签证方案,或缩短停留。
{!meta.checklist["visa-research"] && (
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ marginLeft: 8 }}
onClick={() => persistMeta({
...meta,
checklist: { ...meta.checklist, "visa-research": true },
})}
>
标记「已确认签证」
</button>
)}
</div>
)}
<div className="plan-layout"> <div className="plan-layout">
<div className="plan-main"> <div className="plan-main">
<section className="plan-panel"> <section className="plan-panel">
@ -316,6 +388,7 @@ export default function MovePlanClient({ destinations }: Props) {
<div className="plan-actions"> <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={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={exportTrip} disabled={!trip.length}>导出 MD</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={exportIcs} disabled={!trip.length}>导出日历</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={shareTrip} disabled={!trip.length}>分享</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { persistTrip([]); toast("已清空行程"); }} disabled={!trip.length}>清空</button> <button type="button" className="btn btn-ghost btn-sm" onClick={() => { persistTrip([]); toast("已清空行程"); }} disabled={!trip.length}>清空</button>
</div> </div>
@ -377,6 +450,15 @@ export default function MovePlanClient({ destinations }: Props) {
🛂 {t.visa.text} 🛂 {t.visa.text}
</p> </p>
)} )}
{t.visaOptions.length > 0 && (
<div className="plan-stop-visas">
{t.visaOptions.map((v) => (
<span key={v.id} className="plan-visa-chip" title={`${v.income_req} · ${v.approval_time}`}>
{v.flag} {v.name} · {v.duration}
</span>
))}
</div>
)}
</div> </div>
</li> </li>
))} ))}
@ -384,6 +466,44 @@ export default function MovePlanClient({ destinations }: Props) {
)} )}
</section> </section>
{(tripVisas.length > 0 || (trip.length > 0 && missingVisaCountries.length > 0)) && (
<section className="plan-panel">
<div className="plan-panel-head">
<h2>本行程签证方案</h2>
<Link href="/#visa" className="btn btn-ghost btn-sm">签证指南 →</Link>
</div>
{tripVisas.length > 0 ? (
<div className="plan-visa-grid">
{tripVisas.map((v) => (
<article key={v.id} className="plan-visa-card">
<header>
<span>{v.flag}</span>
<div>
<strong>{v.name}</strong>
<small>{v.country} · {v.badge}</small>
</div>
</header>
<ul>
<li>时长 {v.duration}</li>
<li>收入要求 {v.income_req}</li>
<li>审批 {v.approval_time}</li>
<li>难度 {v.difficulty_label}</li>
</ul>
{v.extra && <p>{v.extra}</p>}
</article>
))}
</div>
) : (
<p className="plan-visa-missing">当前行程国家暂无收录的游民签证条目,请对照停留提醒与官方政策。</p>
)}
{missingVisaCountries.length > 0 && tripVisas.length > 0 && (
<p className="plan-visa-missing">
暂无收录:{missingVisaCountries.join("、")} — 请以官方政策为准,并确认免签天数。
</p>
)}
</section>
)}
{compareRows.length >= 2 && ( {compareRows.length >= 2 && (
<section className="plan-panel"> <section className="plan-panel">
<div className="plan-panel-head"> <div className="plan-panel-head">

View File

@ -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);
}

View File

@ -32,38 +32,45 @@ export const MOVE_CHECKLIST: ChecklistItem[] = [
{ id: "emergency", category: "远程工作", label: "紧急联系人与领馆信息", hint: "存在手机与纸质" }, { id: "emergency", category: "远程工作", label: "紧急联系人与领馆信息", hint: "存在手机与纸质" },
]; ];
export const COUNTRY_STAY: Record<string, { days: number; note: string }> = { export const COUNTRY_STAY: Record<string, { days: number; note: string }> = (() => {
Thailand: { days: 60, note: "免签常见约 60 天;超期需延期或出境" }, const entries: { keys: string[]; days: number; note: string }[] = [
Indonesia: { days: 30, note: "落地签常见 30 天,可按规定延期" }, { keys: ["泰国", "Thailand"], days: 60, note: "免签常见约 60 天;超期需延期或出境" },
Portugal: { days: 90, note: "申根 90/180 天累计,勿只看单次" }, { keys: ["印尼", "Indonesia"], days: 30, note: "落地签常见 30 天,可按规定延期" },
Spain: { days: 90, note: "申根 90/180 天累计" }, { keys: ["葡萄牙", "Portugal"], days: 90, note: "申根 90/180 天累计,勿只看单次" },
Japan: { days: 90, note: "免签通常最长约 90 天" }, { keys: ["西班牙", "Spain"], days: 90, note: "申根 90/180 天累计" },
Mexico: { days: 180, note: "入境许可常见最长约 180 天" }, { keys: ["日本", "Japan"], days: 90, note: "免签通常最长约 90 天" },
Vietnam: { days: 45, note: "免签天数常变,出发前务必核实" }, { keys: ["墨西哥", "Mexico"], days: 180, note: "入境许可常见最长约 180 天" },
Malaysia: { days: 90, note: "多数国籍免签约 90 天" }, { keys: ["越南", "Vietnam"], days: 45, note: "免签天数常变,出发前务必核实" },
"South Korea": { days: 90, note: "免签常见约 90 天" }, { keys: ["马来西亚", "Malaysia"], days: 90, note: "多数国籍免签约 90 天" },
"United States": { days: 90, note: "ESTA 等多为 90 天,不可远程打工身份" }, { keys: ["韩国", "South Korea", "Korea"], days: 90, note: "免签常见约 90 天" },
Colombia: { days: 90, note: "常见 90 天,可延长至约 180 天" }, { keys: ["美国", "United States", "USA"], days: 90, note: "ESTA 等多为 90 天,不可远程打工身份" },
Georgia: { days: 365, note: "多数国籍免签最长约 1 年" }, { keys: ["哥伦比亚", "Colombia"], days: 90, note: "常见 90 天,可延长至约 180 天" },
"United Arab Emirates": { days: 30, note: "落地签常见短签,远程身份另议" }, { keys: ["格鲁吉亚", "Georgia"], days: 365, note: "多数国籍免签最长约 1 年" },
Germany: { days: 90, note: "申根 90/180;长期需签证" }, { keys: ["阿联酋", "United Arab Emirates", "UAE"], days: 30, note: "落地签常见短签,远程身份另议" },
France: { days: 90, note: "申根 90/180;长期需签证" }, { keys: ["德国", "Germany"], days: 90, note: "申根 90/180;长期需签证" },
Italy: { days: 90, note: "申根 90/180;长期需签证" }, { keys: ["法国", "France"], days: 90, note: "申根 90/180;长期需签证" },
Croatia: { days: 90, note: "申根相关规则以出发前官方为准" }, { keys: ["意大利", "Italy"], days: 90, note: "申根 90/180;长期需签证" },
Hungary: { days: 90, note: "申根 90/180" }, { keys: ["克罗地亚", "Croatia"], days: 90, note: "申根相关规则以出发前官方为准" },
Czechia: { days: 90, note: "申根 90/180" }, { keys: ["匈牙利", "Hungary"], days: 90, note: "申根 90/180" },
"Czech Republic": { days: 90, note: "申根 90/180" }, { keys: ["捷克", "Czechia", "Czech Republic"], days: 90, note: "申根 90/180" },
Taiwan: { days: 90, note: "免签政策因护照而异" }, { keys: ["台湾", "Taiwan"], days: 90, note: "免签政策因护照而异" },
Singapore: { days: 30, note: "停留偏短,适合中转或短居" }, { keys: ["新加坡", "Singapore"], days: 30, note: "停留偏短,适合中转或短居" },
Philippines: { days: 30, note: "常见 30 天,可延期" }, { keys: ["菲律宾", "Philippines"], days: 30, note: "常见 30 天,可延期" },
Cambodia: { days: 30, note: "落地签常见 30 天" }, { keys: ["柬埔寨", "Cambodia"], days: 30, note: "落地签常见 30 天" },
"Costa Rica": { days: 90, note: "常见 90 天旅游停留" }, { keys: ["哥斯达黎加", "Costa Rica"], days: 90, note: "常见 90 天旅游停留" },
Argentina: { days: 90, note: "常见 90 天" }, { keys: ["阿根廷", "Argentina"], days: 90, note: "常见 90 天" },
Brazil: { days: 90, note: "常见 90 天" }, { keys: ["巴西", "Brazil"], days: 90, note: "常见 90 天" },
Turkey: { days: 90, note: "常见 90 天,政策因护照而异" }, { keys: ["土耳其", "Turkey"], days: 90, note: "常见 90 天,政策因护照而异" },
India: { days: 30, note: "电子签常见短签,远程工作另核" }, { keys: ["印度", "India"], days: 30, note: "电子签常见短签,远程工作另核" },
Nepal: { days: 30, note: "落地签常见,按档购买天数" }, { keys: ["尼泊尔", "Nepal"], days: 30, note: "落地签常见,按档购买天数" },
}; { keys: ["爱沙尼亚", "Estonia"], days: 90, note: "申根 90/180;长期可看 DNV" },
];
const map: Record<string, { days: number; note: string }> = {};
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 = { const DEFAULT_META: PlanMeta = {
title: "我的旅居计划", title: "我的旅居计划",
@ -110,12 +117,24 @@ export function savePlanMeta(meta: PlanMeta) {
/** 由出发月 + 各站月数推算大致区间文案 */ /** 由出发月 + 各站月数推算大致区间文案 */
export function rangeLabel(startMonth: string, offsetMonths: number, durationMonths: number): string { 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 [y, m] = startMonth.split("-").map(Number);
const start = new Date(y, m - 1 + offsetMonths, 1); const start = new Date(y, m - 1 + offsetMonths, 1);
const end = new Date(y, m - 1 + offsetMonths + durationMonths, 0); const endExclusive = new Date(y, m - 1 + offsetMonths + durationMonths, 1);
const fmt = (d: Date) => `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}`; const endInclusive = new Date(endExclusive.getTime() - 86400000);
return `${fmt(start)} – ${fmt(end)}`; return { start, endInclusive, endExclusive };
} }
export function stayHint(country: string, months: number): { text: string; overLimit: boolean } | null { 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) { if (plannedDays > info.days) {
return { return {
overLimit: true, 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}` };
} }

View File

@ -42,6 +42,7 @@ FILES = [
"frontend/src/lib/matcherPrefs.ts", "frontend/src/lib/matcherPrefs.ts",
"frontend/src/lib/tripStorage.ts", "frontend/src/lib/tripStorage.ts",
"frontend/src/lib/planSync.ts", "frontend/src/lib/planSync.ts",
"frontend/src/lib/planIcs.ts",
"frontend/src/lib/auth.tsx", "frontend/src/lib/auth.tsx",
"frontend/src/lib/api.ts", "frontend/src/lib/api.ts",
"backend/app/services/auth.py", "backend/app/services/auth.py",