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` 分类搜索全部工具 |
|
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
||||||
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出;登录后云端同步 |
|
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证方案、超期提醒、就绪清单、MD/ICS 导出;登录同步 |
|
||||||
| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 |
|
| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 |
|
||||||
| 📜 更新日志 | `/changelog` 迭代记录 |
|
| 📜 更新日志 | `/changelog` 迭代记录 |
|
||||||
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
||||||
|
|||||||
@ -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: "账号同步",
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
[destinations, visas] = await Promise.all([api.getDestinations(), api.getVisas()]);
|
||||||
|
} catch {
|
||||||
try {
|
try {
|
||||||
destinations = await api.getDestinations();
|
destinations = await api.getDestinations();
|
||||||
} catch {
|
} catch {
|
||||||
destinations = [];
|
destinations = [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteShell showFooter>
|
<SiteShell showFooter>
|
||||||
<MovePlanClient destinations={destinations} />
|
<MovePlanClient destinations={destinations} visas={visas} />
|
||||||
</SiteShell>
|
</SiteShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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: "存在手机与纸质" },
|
{ 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}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user