diff --git a/README.md b/README.md
index 461a5a4..21f0531 100644
--- a/README.md
+++ b/README.md
@@ -114,6 +114,8 @@ docker compose up -d
| 🤝 会议黄金时段 | 双时区工作重叠可视化 |
| ☀️ 日照办公窗 | 日出日落与深度工作建议 |
| ⚖️ 行李重量 | 对照航司限额估算装备重量 |
+| 🛂 签证停留倒计时 | 入境日与剩余天数提醒 |
+| 💼 办公点成本 | 月卡 vs 咖啡馆划算度对比 |
| ✨ 今日金句 | 每日游民灵感语录 |
| 😴 时差估算 | 时差跨度与恢复期建议 |
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |
diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx
index 6eed1b2..fc64a9a 100644
--- a/frontend/src/app/changelog/page.tsx
+++ b/frontend/src/app/changelog/page.tsx
@@ -12,11 +12,10 @@ const LOGS = [
date: "2026-08-29",
tag: "新工具",
items: [
- "日照办公窗:按城市月份估算日出日落与深度工作时段",
- "行李重量估算:勾选装备对照航司限额",
- "旅居跑道计算器:存款 + 月开销一眼算出还能飞多久",
- "会议黄金时段:双时区工作重叠可视化时间轴",
- "快捷工具条更新,直达日照 / 行李 / 跑道 / 会议",
+ "签证停留倒计时:入境日 + 额度,剩余天数环形提醒",
+ "办公点成本对比:联合办公月卡 vs 咖啡馆日刷",
+ "日照办公窗、行李重量、旅居跑道、会议黄金时段",
+ "快捷工具条直达签证 / 办公 / 跑道等核心工具",
],
},
{
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 1356810..4160efa 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -8112,3 +8112,188 @@ img { max-width: 100%; display: block; }
.bag-grid { grid-template-columns: 1fr; }
.bag-extra { grid-template-columns: 1fr; }
}
+
+
+/* ===== Visa Stay Countdown ===== */
+.visa-stay-card {
+ display: grid;
+ grid-template-columns: minmax(220px, 280px) 1fr;
+ gap: 28px;
+ padding: 28px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ backdrop-filter: blur(16px);
+}
+.visa-stay-meter { text-align: center; }
+.visa-stay-ring {
+ width: 168px;
+ height: 168px;
+ border-radius: 50%;
+ margin: 0 auto 16px;
+ display: grid;
+ place-items: center;
+}
+.visa-stay-ring-inner {
+ width: 128px;
+ height: 128px;
+ border-radius: 50%;
+ background: var(--bg-secondary);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+.visa-stay-ring-inner strong {
+ font-family: var(--font-display);
+ font-size: 2.6rem;
+ line-height: 1;
+}
+.visa-stay-ring-inner small { color: var(--text-muted); margin-top: 4px; }
+.visa-stay-level span { font-weight: 700; font-size: 1.05rem; }
+.visa-stay-level p {
+ color: var(--text-secondary);
+ font-size: 0.88rem;
+ margin-top: 6px;
+ line-height: 1.45;
+}
+.visa-stay-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.visa-stay-form > label,
+.workspot-controls > label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.visa-stay-form > label > span,
+.workspot-controls > label > span {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+.visa-stay-form input,
+.visa-stay-form select,
+.workspot-controls select {
+ background: rgba(255,255,255,0.04);
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ padding: 10px 12px;
+ font-size: 1rem;
+}
+.visa-stay-hint {
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--accent-3);
+}
+.visa-stay-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ padding: 12px;
+ background: rgba(255,255,255,0.03);
+ border-radius: var(--radius-md);
+}
+.visa-stay-stats small { display: block; color: var(--text-muted); }
+.visa-stay-stats strong { font-size: 1.05rem; }
+
+/* ===== Work Spot Cost ===== */
+.workspot-card {
+ display: grid;
+ grid-template-columns: 1fr 1.1fr;
+ gap: 22px;
+ padding: 24px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ backdrop-filter: blur(16px);
+}
+.workspot-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.workspot-controls input[type=range],
+.visa-stay-form input[type=range] {
+ width: 100%;
+ accent-color: var(--accent-3);
+}
+.workspot-modes {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+.workspot-mode {
+ padding: 10px 8px;
+ border-radius: var(--radius-md);
+ border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03);
+ color: inherit;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 0.85rem;
+ transition: all 0.2s;
+}
+.workspot-mode:hover,
+.workspot-mode.active {
+ border-color: rgba(78, 205, 196, 0.4);
+ background: rgba(78, 205, 196, 0.1);
+}
+.workspot-result {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.workspot-hero {
+ padding: 18px;
+ border-radius: var(--radius-md);
+ background: linear-gradient(135deg, rgba(78,205,196,0.12), rgba(255,107,107,0.08));
+ border: 1px solid rgba(255,255,255,0.08);
+}
+.workspot-hero small { color: var(--text-muted); display: block; }
+.workspot-hero strong {
+ font-family: var(--font-display);
+ font-size: 2.2rem;
+ display: block;
+ margin: 4px 0 6px;
+}
+.workspot-hero > span { font-weight: 600; font-size: 0.92rem; }
+.workspot-compare {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+.workspot-compare > div {
+ padding: 12px;
+ border-radius: var(--radius-md);
+ background: rgba(255,255,255,0.03);
+ border: 1px solid rgba(255,255,255,0.06);
+ transition: all 0.2s;
+}
+.workspot-compare > div.on {
+ border-color: rgba(78, 205, 196, 0.4);
+ background: rgba(78, 205, 196, 0.1);
+}
+.workspot-compare small { display: block; color: var(--text-muted); font-size: 0.75rem; }
+.workspot-compare strong { display: block; margin: 4px 0; font-size: 1.05rem; }
+.workspot-compare em {
+ font-style: normal;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ line-height: 1.35;
+}
+.workspot-tip {
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ line-height: 1.45;
+}
+
+@media (max-width: 900px) {
+ .visa-stay-card,
+ .workspot-card { grid-template-columns: 1fr; }
+ .workspot-compare { grid-template-columns: 1fr; }
+ .visa-stay-stats { grid-template-columns: 1fr; }
+}
diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx
index a3bff3c..7fc29cc 100644
--- a/frontend/src/app/tools/page.tsx
+++ b/frontend/src/app/tools/page.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import SiteShell from "@/components/SiteShell";
-const FEATURED = ["daylight", "bag-weight", "runway", "meetings", "trip", "focus"];
+const FEATURED = ["visa-stay", "workspot", "runway", "daylight", "meetings", "bag-weight"];
export default function ToolsHubPage() {
const [filter, setFilter] = useState("all");
@@ -41,7 +41,7 @@ export default function ToolsHubPage() {
✨ 本周推荐
- 新上线 · 日照窗 & 行李重量
+ 新上线 · 停留倒计时 & 办公成本
{featured.map((t) => (
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx
index 77bc88f..af293a2 100644
--- a/frontend/src/components/HomeClient.tsx
+++ b/frontend/src/components/HomeClient.tsx
@@ -44,11 +44,13 @@ const CurrencyConverter = lazy(() => import("./CurrencyConverter"));
const MeetingWindow = lazy(() => import("./MeetingWindow"));
const DaylightWorkHours = lazy(() => import("./DaylightWorkHours"));
const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
+const WorkSpotCost = lazy(() => import("./WorkSpotCost"));
const FocusTimer = lazy(() => import("./FocusTimer"));
const NomadScore = lazy(() => import("./NomadScore"));
const Lifestyle = lazy(() => import("./Lifestyle"));
const Charts = lazy(() => import("./Charts"));
const VisaSection = lazy(() => import("./VisaSection"));
+const VisaStayCountdown = lazy(() => import("./VisaStayCountdown"));
const InsuranceGuide = lazy(() => import("./InsuranceGuide"));
const EmergencyContacts = lazy(() => import("./EmergencyContacts"));
const AtmCashGuide = lazy(() => import("./AtmCashGuide"));
@@ -177,11 +179,13 @@ export default function HomeClient(props: Props) {
+
+
diff --git a/frontend/src/components/ToolsStrip.tsx b/frontend/src/components/ToolsStrip.tsx
index 2444c55..ffab6ce 100644
--- a/frontend/src/components/ToolsStrip.tsx
+++ b/frontend/src/components/ToolsStrip.tsx
@@ -3,12 +3,12 @@
import Link from "next/link";
const QUICK = [
+ { href: "/#visa-stay", emoji: "🛂", label: "签证" },
+ { href: "/#workspot", emoji: "💼", label: "办公" },
{ href: "/#runway", emoji: "🚀", label: "跑道" },
{ href: "/#daylight", emoji: "☀️", label: "日照" },
{ href: "/#meetings", emoji: "🤝", label: "会议" },
- { href: "/#bag-weight", emoji: "⚖️", label: "行李" },
{ href: "/#trip", emoji: "🗓️", label: "行程" },
- { href: "/#calculator", emoji: "🧮", label: "费用" },
{ href: "/#focus", emoji: "⏱️", label: "专注" },
{ href: "/tools", emoji: "🛠️", label: "全部" },
];
diff --git a/frontend/src/components/VisaStayCountdown.tsx b/frontend/src/components/VisaStayCountdown.tsx
new file mode 100644
index 0000000..d5a96b4
--- /dev/null
+++ b/frontend/src/components/VisaStayCountdown.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+const STORAGE_KEY = "nomadro-visa-stay";
+
+const PRESETS: Record
= {
+ chiangmai: { days: 60, label: "免签/落地签常见 60 天" },
+ bali: { days: 30, label: "落地签常见 30 天" },
+ lisbon: { days: 90, label: "申根短签常见 90 天" },
+ barcelona: { days: 90, label: "申根短签常见 90 天" },
+ mexico: { days: 180, label: "常见最长约 180 天" },
+ tokyo: { days: 90, label: "短期停留常见 90 天" },
+};
+
+interface Saved {
+ slug: string;
+ entry: string;
+ days: number;
+}
+
+interface Props {
+ destinations: Destination[];
+}
+
+function daysBetween(a: string, b: string): number {
+ const t0 = new Date(a + "T00:00:00").getTime();
+ const t1 = new Date(b + "T00:00:00").getTime();
+ return Math.floor((t1 - t0) / 86400000);
+}
+
+export default function VisaStayCountdown({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
+ const [entry, setEntry] = useState(() => new Date().toISOString().slice(0, 10));
+ const [days, setDays] = useState(60);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const s = JSON.parse(raw) as Saved;
+ if (s.slug) setSlug(s.slug);
+ if (s.entry) setEntry(s.entry);
+ if (s.days) setDays(s.days);
+ }
+ } catch { /* ignore */ }
+ setReady(true);
+ }, []);
+
+ useEffect(() => {
+ if (!ready) return;
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ slug, entry, days }));
+ }, [slug, entry, days, ready]);
+
+ const today = new Date().toISOString().slice(0, 10);
+ const used = Math.max(0, daysBetween(entry, today));
+ const left = days - used;
+ const exitDate = useMemo(() => {
+ const d = new Date(entry + "T00:00:00");
+ d.setDate(d.getDate() + days - 1);
+ return d.toISOString().slice(0, 10);
+ }, [entry, days]);
+
+ const pct = Math.min(Math.max((used / Math.max(days, 1)) * 100, 0), 100);
+ const risk =
+ left <= 0 ? { label: "已到期/超期", color: "#FF6B6B", tip: "请立即确认离境或延期,勿依赖本工具作法律依据" } :
+ left <= 7 ? { label: "紧急窗口", color: "#FF6B6B", tip: "一周内到期,优先订机票与查政策" } :
+ left <= 21 ? { label: "临近到期", color: "#FFE66D", tip: "开始准备下一站或续签材料" } :
+ { label: "时间充裕", color: "#4ECDC4", tip: "安心旅居,记得关注政策变动" };
+
+ const dest = destinations.find((d) => d.slug === slug);
+ const applyPreset = (s: string) => {
+ setSlug(s);
+ const p = PRESETS[s];
+ if (p) setDays(p.days);
+ };
+
+ return (
+
+
+
+
🛂 VISA STAY
+
签证停留倒计时
+
记录入境日与允许天数,剩余停留一目了然(仅供参考)
+
+
+
+
+
+
+
{risk.label}
+
{risk.tip}
+
+
+
+
+
+
+
+ {PRESETS[slug] && (
+
{dest?.emoji} {PRESETS[slug].label}
+ )}
+
+
+
已停留{used} 天
+
额度{days} 天
+
建议离境前{exitDate}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/WorkSpotCost.tsx b/frontend/src/components/WorkSpotCost.tsx
new file mode 100644
index 0000000..a73a03f
--- /dev/null
+++ b/frontend/src/components/WorkSpotCost.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+/** Rough monthly coworking vs cafe day-pass economics (CNY). */
+const CITY_COST: Record = {
+ chiangmai: { cowork: 900, cafeDay: 35, transitDay: 15 },
+ bali: { cowork: 1200, cafeDay: 45, transitDay: 25 },
+ lisbon: { cowork: 1800, cafeDay: 55, transitDay: 30 },
+ barcelona: { cowork: 2000, cafeDay: 60, transitDay: 35 },
+ mexico: { cowork: 1400, cafeDay: 40, transitDay: 20 },
+ tokyo: { cowork: 2800, cafeDay: 70, transitDay: 40 },
+};
+
+interface Props {
+ destinations: Destination[];
+}
+
+export default function WorkSpotCost({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
+ const [days, setDays] = useState(20);
+ const [mode, setMode] = useState<"cowork" | "cafe" | "mix">("mix");
+ const [mixCoworkDays, setMixCoworkDays] = useState(12);
+
+ const base = CITY_COST[slug] || { cowork: 1500, cafeDay: 45, transitDay: 20 };
+ const dest = destinations.find((d) => d.slug === slug);
+
+ const result = useMemo(() => {
+ const cafeOnly = days * (base.cafeDay + base.transitDay);
+ const coworkOnly = base.cowork;
+ const mixDays = Math.min(mixCoworkDays, days);
+ const cafeDays = Math.max(days - mixDays, 0);
+ const mix = base.cowork + cafeDays * (base.cafeDay + base.transitDay);
+
+ const pick = mode === "cowork" ? coworkOnly : mode === "cafe" ? cafeOnly : mix;
+ const winner =
+ Math.min(coworkOnly, cafeOnly, mix) === coworkOnly ? "联合办公月卡" :
+ Math.min(coworkOnly, cafeOnly, mix) === cafeOnly ? "咖啡馆日刷" :
+ "混合策略";
+
+ return { cafeOnly, coworkOnly, mix, pick, winner };
+ }, [base, days, mode, mixCoworkDays]);
+
+ const saveVsCafe = result.cafeOnly - result.pick;
+ const saveColor = saveVsCafe >= 0 ? "#4ECDC4" : "#FF6B6B";
+
+ return (
+
+
+
+
💼 WORK SPOT
+
办公点成本对比
+
联合办公月卡 vs 咖啡馆日刷,算出哪种更划算
+
+
+
+
+
+
+
+ {([
+ ["cowork", "🏢 月卡"],
+ ["cafe", "☕ 咖啡馆"],
+ ["mix", "🔀 混合"],
+ ] as const).map(([k, label]) => (
+
+ ))}
+
+ {mode === "mix" && (
+
+ )}
+
+
+
+
+ {dest?.emoji} 本月估算
+ ¥{Math.round(result.pick).toLocaleString()}
+
+ {saveVsCafe >= 0
+ ? `比纯咖啡馆省 ¥${Math.round(saveVsCafe).toLocaleString()}`
+ : `比纯咖啡馆多花 ¥${Math.round(-saveVsCafe).toLocaleString()}`}
+
+
+
+
+
+ 联合办公月卡
+ ¥{base.cowork.toLocaleString()}
+ 含工位/网速/打印
+
+
+ 咖啡馆×{days}天
+ ¥{Math.round(result.cafeOnly).toLocaleString()}
+ 含饮品+往返约 ¥{base.cafeDay + base.transitDay}/天
+
+
+ 混合方案
+ ¥{Math.round(result.mix).toLocaleString()}
+ 月卡 + 剩余咖啡馆
+
+
+
+
+ 建议优先:{result.winner}
+ · 参考价会随街区与旺季浮动,落地后再微调
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts
index 0202f88..9bc81cc 100644
--- a/frontend/src/lib/tools.ts
+++ b/frontend/src/lib/tools.ts
@@ -25,10 +25,12 @@ export const TOOL_LINKS: ToolLink[] = [
{ id: "meetings", emoji: "🤝", title: "会议黄金时段", desc: "对齐可开会窗口", href: "/#meetings", category: "work" },
{ id: "jetlag", emoji: "😴", title: "时差恢复", desc: "恢复期建议", href: "/#jetlag", category: "work" },
{ id: "wifi", emoji: "📶", title: "网速评级", desc: "远程办公是否够用", href: "/#wifi", category: "work" },
+ { id: "workspot", emoji: "💼", title: "办公点成本", desc: "月卡 vs 咖啡馆划算度", href: "/#workspot", category: "work" },
{ id: "focus", emoji: "⏱️", title: "专注时钟", desc: "番茄钟深度工作", href: "/#focus", category: "work" },
{ id: "coworking", emoji: "💻", title: "联合办公", desc: "热门 Co-working", href: "/#coworking", category: "work" },
{ id: "cafes", emoji: "☕", title: "咖啡馆", desc: "办公友好咖啡店", href: "/#cafes", category: "work" },
{ id: "visa", emoji: "📋", title: "签证指南", desc: "政策与智能匹配", href: "/#visa", category: "life" },
+ { id: "visa-stay", emoji: "🛂", title: "停留倒计时", desc: "签证剩余天数提醒", href: "/#visa-stay", category: "life" },
{ id: "housing", emoji: "🏡", title: "住宿指南", desc: "Coliving / 公寓方案", href: "/#housing", category: "life" },
{ id: "sim", emoji: "📱", title: "上网方案", desc: "SIM / eSIM 推荐", href: "/#sim", category: "life" },
{ id: "plugs", emoji: "🔌", title: "电源插座", desc: "电压与插头类型", href: "/#plugs", category: "life" },