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:
parent
a1b2408ed0
commit
9a6474a69a
@ -136,7 +136,7 @@ docker compose up -d
|
||||
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
|
||||
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
|
||||
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
||||
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出;登录后云端同步 |
|
||||
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证方案、超期提醒、就绪清单、MD/ICS 导出;登录同步 |
|
||||
| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 |
|
||||
| 📜 更新日志 | `/changelog` 迭代记录 |
|
||||
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
||||
|
||||
@ -8,6 +8,15 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
const LOGS = [
|
||||
{
|
||||
date: "2026-08-29",
|
||||
tag: "计划深化",
|
||||
items: [
|
||||
"修复停留签证提醒(中英文国家名对齐),超期站点醒目提示",
|
||||
"计划页接入签证目录:按行程国家展示游民签证方案与收入/审批信息",
|
||||
"导出日历(.ics):设出发月后可导入手机 / Google Calendar",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-29",
|
||||
tag: "账号同步",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<SiteShell showFooter>
|
||||
<MovePlanClient destinations={destinations} />
|
||||
<MovePlanClient destinations={destinations} visas={visas} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<TripItem[]>([]);
|
||||
@ -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) {
|
||||
</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-main">
|
||||
<section className="plan-panel">
|
||||
@ -316,6 +388,7 @@ export default function MovePlanClient({ destinations }: Props) {
|
||||
<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={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={() => { persistTrip([]); toast("已清空行程"); }} disabled={!trip.length}>清空</button>
|
||||
</div>
|
||||
@ -377,6 +450,15 @@ export default function MovePlanClient({ destinations }: Props) {
|
||||
🛂 {t.visa.text}
|
||||
</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>
|
||||
</li>
|
||||
))}
|
||||
@ -384,6 +466,44 @@ export default function MovePlanClient({ destinations }: Props) {
|
||||
)}
|
||||
</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 && (
|
||||
<section className="plan-panel">
|
||||
<div className="plan-panel-head">
|
||||
|
||||
76
frontend/src/lib/planIcs.ts
Normal file
76
frontend/src/lib/planIcs.ts
Normal 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);
|
||||
}
|
||||
@ -32,38 +32,45 @@ export const MOVE_CHECKLIST: ChecklistItem[] = [
|
||||
{ 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: "落地签常见,按档购买天数" },
|
||||
};
|
||||
export const COUNTRY_STAY: Record<string, { days: number; note: string }> = (() => {
|
||||
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<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 = {
|
||||
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}` };
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user