Add runway calculator and meeting golden-hour timeline with polished UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 01:12:33 -05:00
parent 2dd448874e
commit b4f1846d65
9 changed files with 707 additions and 3 deletions

View File

@ -110,6 +110,8 @@ docker compose up -d
| 📅 活动日历 | Meetup / 工作坊 / 线上活动 | | 📅 活动日历 | Meetup / 工作坊 / 线上活动 |
| 📶 网速评级 | 远程办公 WiFi 评级与排行 | | 📶 网速评级 | 远程办公 WiFi 评级与排行 |
| 🎯 启动金目标 | 存款进度追踪,预估出发时间 | | 🎯 启动金目标 | 存款进度追踪,预估出发时间 |
| 🚀 旅居跑道 | 存款 ÷ 月开销,算出还能飞多久 |
| 🤝 会议黄金时段 | 双时区工作重叠可视化 |
| ✨ 今日金句 | 每日游民灵感语录 | | ✨ 今日金句 | 每日游民灵感语录 |
| 😴 时差估算 | 时差跨度与恢复期建议 | | 😴 时差估算 | 时差跨度与恢复期建议 |
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 | | 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |

View File

@ -8,6 +8,15 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-08-29",
tag: "新工具",
items: [
"旅居跑道计算器:存款 + 月开销一眼算出还能飞多久",
"会议黄金时段:双时区工作重叠可视化时间轴",
"快捷工具条更新,直达跑道 / 会议 / 汇率",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "体验优化", tag: "体验优化",
@ -16,6 +25,7 @@ const LOGS = [
"首页常用工具快捷条 + 下方模块懒加载", "首页常用工具快捷条 + 下方模块懒加载",
"行程支持下载 Markdown 文件", "行程支持下载 Markdown 文件",
"新增饮食饮水注意事项", "新增饮食饮水注意事项",
"修复生产环境 CSS 解析失败导致的页面异常",
], ],
}, },
{ {

View File

@ -7552,3 +7552,323 @@ img { max-width: 100%; display: block; }
@media (max-width: 480px) { @media (max-width: 480px) {
.tools-strip-list { grid-template-columns: repeat(4, 1fr); } .tools-strip-list { grid-template-columns: repeat(4, 1fr); }
} }
/* ===== Runway Calculator ===== */
.runway-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);
}
.runway-pulse { animation: runwayPulse 0.45s ease; }
@keyframes runwayPulse {
0% { transform: scale(1); }
50% { transform: scale(1.01); box-shadow: var(--shadow-glow); }
100% { transform: scale(1); }
}
.runway-meter { text-align: center; }
.runway-ring {
width: 168px;
height: 168px;
border-radius: 50%;
margin: 0 auto 16px;
display: grid;
place-items: center;
}
.runway-ring-inner {
width: 128px;
height: 128px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.runway-ring-inner strong {
font-family: var(--font-display);
font-size: 2.4rem;
line-height: 1;
}
.runway-ring-inner small { color: var(--text-muted); margin-top: 4px; }
.runway-level span {
font-weight: 700;
font-size: 1.05rem;
}
.runway-level p {
color: var(--text-secondary);
font-size: 0.88rem;
margin-top: 6px;
line-height: 1.45;
}
.runway-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.runway-form > label {
display: flex;
flex-direction: column;
gap: 6px;
}
.runway-form > label > span {
font-size: 0.85rem;
color: var(--text-secondary);
}
.runway-form input[type=number],
.meeting-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;
}
.runway-dests small {
display: block;
color: var(--text-muted);
margin-bottom: 8px;
}
.runway-dest-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.runway-dest {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 10px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
text-align: left;
color: inherit;
}
.runway-dest:hover,
.runway-dest.active {
border-color: rgba(78, 205, 196, 0.4);
background: rgba(78, 205, 196, 0.08);
}
.runway-dest em { font-style: normal; font-weight: 600; font-size: 0.88rem; }
.runway-dest small { color: var(--text-muted); }
.runway-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px;
background: rgba(255,255,255,0.03);
border-radius: var(--radius-md);
}
.runway-summary small { color: var(--text-muted); display: block; }
.runway-summary strong { font-size: 1.1rem; }
.runway-milestones {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 4px;
}
.runway-ms {
text-align: center;
opacity: 0.35;
transition: opacity 0.25s;
}
.runway-ms.on { opacity: 1; }
.runway-ms i {
display: block;
height: 4px;
border-radius: 4px;
background: var(--accent-3);
margin-bottom: 6px;
}
.runway-ms span { font-size: 0.75rem; color: var(--text-secondary); }
/* ===== Meeting Window ===== */
.meeting-card {
padding: 24px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
backdrop-filter: blur(16px);
display: flex;
flex-direction: column;
gap: 20px;
}
.meeting-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 18px;
}
.meeting-controls label {
display: flex;
flex-direction: column;
gap: 6px;
}
.meeting-controls label > span {
font-size: 0.85rem;
color: var(--text-secondary);
}
.meeting-range {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: center;
}
.meeting-range em {
grid-column: 1 / -1;
font-style: normal;
font-size: 0.9rem;
color: var(--accent-3);
font-weight: 600;
}
.meeting-range input[type=range] {
width: 100%;
accent-color: var(--accent-3);
}
.meeting-score {
padding: 16px 18px;
border-radius: var(--radius-md);
border: 1px solid;
background: rgba(255,255,255,0.03);
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 14px;
}
.meeting-score strong {
font-family: var(--font-display);
font-size: 1.8rem;
}
.meeting-score > span { font-weight: 700; }
.meeting-score p {
flex: 1 1 100%;
color: var(--text-secondary);
font-size: 0.92rem;
margin: 0;
}
.meeting-legend {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-bottom: 10px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.meeting-legend i {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 6px;
vertical-align: middle;
}
.meeting-legend .m-home { background: rgba(167, 139, 250, 0.7); }
.meeting-legend .m-peer { background: rgba(255, 230, 109, 0.7); }
.meeting-legend .m-both { background: #4ECDC4; }
.meeting-bars {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
min-height: 56px;
}
.meeting-cell {
border-radius: 4px;
background: rgba(255,255,255,0.05);
position: relative;
transition: transform 0.15s, background 0.15s;
min-height: 48px;
}
.meeting-cell.home { background: rgba(167, 139, 250, 0.28); }
.meeting-cell.peer { background: rgba(255, 230, 109, 0.22); }
.meeting-cell.home.peer { background: rgba(78, 205, 196, 0.25); }
.meeting-cell.overlap {
background: linear-gradient(180deg, #4ECDC4, #3db8b0);
box-shadow: 0 0 12px rgba(78, 205, 196, 0.35);
}
.meeting-cell:hover { transform: translateY(-2px); }
.meeting-cell span {
position: absolute;
bottom: 4px;
left: 0;
right: 0;
text-align: center;
font-size: 0.65rem;
color: var(--text-muted);
}
.meeting-axis {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 0.75rem;
color: var(--text-muted);
}
/* Tools hub featured */
.tools-featured {
margin: 8px 0 28px;
padding: 18px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-xl);
}
.tools-featured-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
gap: 12px;
}
.tools-featured-head span {
font-size: 0.85rem;
color: var(--accent-3);
}
.tools-featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.tools-featured-card {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px;
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;
}
.tools-featured-card:hover {
border-color: rgba(78, 205, 196, 0.35);
background: rgba(78, 205, 196, 0.08);
transform: translateY(-2px);
}
.tools-featured-card > span { font-size: 1.5rem; line-height: 1; }
.tools-featured-card h3 {
font-size: 0.95rem;
margin-bottom: 4px;
}
.tools-featured-card p {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0;
}
@media (max-width: 900px) {
.runway-card { grid-template-columns: 1fr; }
.runway-dest-list { grid-template-columns: repeat(2, 1fr); }
.meeting-controls { grid-template-columns: 1fr; }
.tools-featured-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
.tools-featured-grid { grid-template-columns: 1fr; }
.meeting-bars { gap: 2px; }
.meeting-cell { min-height: 36px; }
}

View File

@ -5,10 +5,17 @@ import Link from "next/link";
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools"; import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
const FEATURED = ["runway", "meetings", "trip", "calculator", "visa", "focus"];
export default function ToolsHubPage() { export default function ToolsHubPage() {
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const featured = useMemo(
() => FEATURED.map((id) => TOOL_LINKS.find((t) => t.id === id)).filter((t): t is NonNullable<typeof t> => !!t),
[]
);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((t) => t.category === filter); let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((t) => t.category === filter);
if (q.trim()) { if (q.trim()) {
@ -31,6 +38,24 @@ export default function ToolsHubPage() {
<p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p> <p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p>
</div> </div>
<div className="tools-featured">
<div className="tools-featured-head">
<strong>✨ 本周推荐</strong>
<span>新上线 · 跑道 & 会议窗口</span>
</div>
<div className="tools-featured-grid">
{featured.map((t) => (
<Link key={t.id} href={t.href} className="tools-featured-card">
<span>{t.emoji}</span>
<div>
<h3>{t.title}</h3>
<p>{t.desc}</p>
</div>
</Link>
))}
</div>
</div>
<div className="tools-hub-toolbar"> <div className="tools-hub-toolbar">
<input <input
className="tools-hub-search" className="tools-hub-search"

View File

@ -36,10 +36,12 @@ const TimezoneBoard = lazy(() => import("./TimezoneBoard"));
const JetLagEstimator = lazy(() => import("./JetLagEstimator")); const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
const CostCompare = lazy(() => import("./CostCompare")); const CostCompare = lazy(() => import("./CostCompare"));
const SavingsGoal = lazy(() => import("./SavingsGoal")); const SavingsGoal = lazy(() => import("./SavingsGoal"));
const RunwayCalculator = lazy(() => import("./RunwayCalculator"));
const ExpenseTracker = lazy(() => import("./ExpenseTracker")); const ExpenseTracker = lazy(() => import("./ExpenseTracker"));
const TaxDayCounter = lazy(() => import("./TaxDayCounter")); const TaxDayCounter = lazy(() => import("./TaxDayCounter"));
const FlightCost = lazy(() => import("./FlightCost")); const FlightCost = lazy(() => import("./FlightCost"));
const CurrencyConverter = lazy(() => import("./CurrencyConverter")); const CurrencyConverter = lazy(() => import("./CurrencyConverter"));
const MeetingWindow = lazy(() => import("./MeetingWindow"));
const WifiWorkScore = lazy(() => import("./WifiWorkScore")); const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
const FocusTimer = lazy(() => import("./FocusTimer")); const FocusTimer = lazy(() => import("./FocusTimer"));
const NomadScore = lazy(() => import("./NomadScore")); const NomadScore = lazy(() => import("./NomadScore"));
@ -165,10 +167,12 @@ export default function HomeClient(props: Props) {
<Calculator destinations={props.destinations} /> <Calculator destinations={props.destinations} />
<CostCompare destinations={props.destinations} /> <CostCompare destinations={props.destinations} />
<SavingsGoal destinations={props.destinations} /> <SavingsGoal destinations={props.destinations} />
<RunwayCalculator destinations={props.destinations} />
<ExpenseTracker /> <ExpenseTracker />
<TaxDayCounter /> <TaxDayCounter />
<FlightCost destinations={props.destinations} /> <FlightCost destinations={props.destinations} />
<CurrencyConverter /> <CurrencyConverter />
<MeetingWindow destinations={props.destinations} />
<WifiWorkScore destinations={props.destinations} /> <WifiWorkScore destinations={props.destinations} />
<FocusTimer /> <FocusTimer />
<NomadScore /> <NomadScore />

View File

@ -0,0 +1,166 @@
"use client";
import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
const TZ_OFFSETS: Record<string, number> = {
bali: 8,
lisbon: 0,
chiangmai: 7,
mexico: -6,
barcelona: 1,
tokyo: 9,
};
const HOME_OPTIONS = [
{ key: "cn", label: "中国 (UTC+8)", offset: 8 },
{ key: "us-east", label: "美东 (UTC-5)", offset: -5 },
{ key: "us-west", label: "美西 (UTC-8)", offset: -8 },
{ key: "eu", label: "西欧 (UTC+1)", offset: 1 },
{ key: "uk", label: "英国 (UTC+0)", offset: 0 },
{ key: "sg", label: "新加坡 (UTC+8)", offset: 8 },
];
interface Props {
destinations: Destination[];
}
function hourInTz(baseHour: number, fromOffset: number, toOffset: number): number {
return (baseHour - fromOffset + toOffset + 48) % 24;
}
export default function MeetingWindow({ destinations }: Props) {
const [homeKey, setHomeKey] = useState("cn");
const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
const [workStart, setWorkStart] = useState(9);
const [workEnd, setWorkEnd] = useState(18);
const [peerStart, setPeerStart] = useState(9);
const [peerEnd, setPeerEnd] = useState(18);
const home = HOME_OPTIONS.find((h) => h.key === homeKey)!;
const dest = destinations.find((d) => d.slug === slug) || destinations[0];
const destOffset = TZ_OFFSETS[slug] ?? 8;
const hours = useMemo(() => {
return Array.from({ length: 24 }, (_, h) => {
const homeActive = h >= workStart && h < workEnd;
const destHour = hourInTz(h, home.offset, destOffset);
const peerActive = destHour >= peerStart && destHour < peerEnd;
const overlap = homeActive && peerActive;
return { h, destHour, homeActive, peerActive, overlap };
});
}, [home.offset, destOffset, workStart, workEnd, peerStart, peerEnd]);
const overlapCount = hours.filter((x) => x.overlap).length;
const bestWindows = useMemo(() => {
const ranges: { start: number; end: number }[] = [];
let cur: { start: number; end: number } | null = null;
hours.forEach((x) => {
if (x.overlap) {
if (!cur) cur = { start: x.h, end: x.h + 1 };
else cur.end = x.h + 1;
} else if (cur) {
ranges.push(cur);
cur = null;
}
});
if (cur) ranges.push(cur);
return ranges;
}, [hours]);
const score =
overlapCount >= 6 ? { label: "黄金协作", color: "#4ECDC4" } :
overlapCount >= 4 ? { label: "可排会", color: "#FFE66D" } :
overlapCount >= 2 ? { label: "窗口偏窄", color: "#F472B6" } :
{ label: "异步为主", color: "#FF6B6B" };
const fmt = (h: number) => `${String(h).padStart(2, "0")}:00`;
return (
<section className="section meeting-section" id="meetings">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🤝 MEETINGS</span>
<h2>会议黄金时段</h2>
<p>对齐你与目的地的工作时间,一眼找到可开会窗口</p>
</div>
<div className="meeting-card reveal">
<div className="meeting-controls">
<label>
<span>你的时区</span>
<select value={homeKey} onChange={(e) => setHomeKey(e.target.value)}>
{HOME_OPTIONS.map((h) => (
<option key={h.key} value={h.key}>{h.label}</option>
))}
</select>
</label>
<label>
<span>旅居城市</span>
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
{destinations.map((d) => {
const off = TZ_OFFSETS[d.slug];
const label = off === undefined ? "?" : `${off >= 0 ? "+" : ""}${off}`;
return (
<option key={d.slug} value={d.slug}>
{d.emoji} {d.name} (UTC{label})
</option>
);
})}
</select>
</label>
<label>
<span>你的工作时段</span>
<div className="meeting-range">
<input type="range" min={6} max={22} value={workStart} onChange={(e) => setWorkStart(+e.target.value)} />
<input type="range" min={7} max={23} value={workEnd} onChange={(e) => setWorkEnd(Math.max(workStart + 1, +e.target.value))} />
<em>{fmt(workStart)} – {fmt(workEnd)}</em>
</div>
</label>
<label>
<span>{dest?.emoji} {dest?.name} 本地工作时段</span>
<div className="meeting-range">
<input type="range" min={6} max={22} value={peerStart} onChange={(e) => setPeerStart(+e.target.value)} />
<input type="range" min={7} max={23} value={peerEnd} onChange={(e) => setPeerEnd(Math.max(peerStart + 1, +e.target.value))} />
<em>{fmt(peerStart)} – {fmt(peerEnd)}</em>
</div>
</label>
</div>
<div className="meeting-score" style={{ borderColor: score.color }}>
<strong style={{ color: score.color }}>{overlapCount}h</strong>
<span style={{ color: score.color }}>{score.label}</span>
<p>
{bestWindows.length === 0
? "几乎没有重叠,建议以异步沟通为主"
: `建议窗口:${bestWindows.map((w) => `${fmt(w.start)}–${fmt(w.end)}`).join("、")}(以你的时区计)`}
</p>
</div>
<div className="meeting-timeline" role="img" aria-label="24小时重叠时间轴">
<div className="meeting-legend">
<span><i className="m-home" /> 你方工作</span>
<span><i className="m-peer" /> 对方工作</span>
<span><i className="m-both" /> 重叠黄金</span>
</div>
<div className="meeting-bars">
{hours.map((x) => (
<div
key={x.h}
className={`meeting-cell${x.overlap ? " overlap" : ""}${x.homeActive ? " home" : ""}${x.peerActive ? " peer" : ""}`}
title={`你 ${fmt(x.h)} · 当地 ${fmt(x.destHour)}`}
>
<span>{x.h % 3 === 0 ? x.h : ""}</span>
</div>
))}
</div>
<div className="meeting-axis">
<span>你的时区 · 00–23</span>
<span>{dest?.emoji} 当地此刻对应见色块悬停</span>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,175 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
const STORAGE_KEY = "nomadro-runway";
interface Props {
destinations: Destination[];
}
interface Saved {
savings: number;
monthly: number;
income: number;
slug: string;
}
export default function RunwayCalculator({ destinations }: Props) {
const [savings, setSavings] = useState(80000);
const [monthly, setMonthly] = useState(4500);
const [income, setIncome] = useState(0);
const [slug, setSlug] = useState("");
const [ready, setReady] = useState(false);
const [pulse, setPulse] = useState(false);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const s = JSON.parse(raw) as Saved;
setSavings(s.savings ?? 80000);
setMonthly(s.monthly ?? 4500);
setIncome(s.income ?? 0);
setSlug(s.slug ?? "");
}
} catch { /* ignore */ }
setReady(true);
}, []);
useEffect(() => {
if (!ready) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ savings, monthly, income, slug }));
}, [savings, monthly, income, slug, ready]);
const netBurn = Math.max(monthly - income, 0);
const months = useMemo(() => {
if (netBurn <= 0) return income > 0 ? 999 : 0;
return Math.floor(savings / netBurn * 10) / 10;
}, [savings, netBurn, income]);
const pct = Math.min((months / 18) * 100, 100);
const level =
months >= 12 ? { label: "从容远航", color: "#4ECDC4", tip: "可以大胆规划长线旅居" } :
months >= 6 ? { label: "稳健起步", color: "#FFE66D", tip: "适合 1–2 站深度停留" } :
months >= 3 ? { label: "短途试水", color: "#F472B6", tip: "建议选低成本城市先验证" } :
{ label: "需要蓄力", color: "#FF6B6B", tip: "先攒启动金或提高远程收入" };
const applyDest = (s: string) => {
const d = destinations.find((x) => x.slug === s);
if (!d) return;
setSlug(s);
setMonthly(d.cost);
setPulse(true);
setTimeout(() => setPulse(false), 500);
};
const milestones = [
{ m: 3, label: "试水期" },
{ m: 6, label: "半程" },
{ m: 12, label: "一年" },
{ m: 18, label: "长线" },
];
return (
<section className="section runway-section" id="runway">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🚀 RUNWAY</span>
<h2>旅居跑道计算器</h2>
<p>用存款与月开销,算出你还能自由飞多久</p>
</div>
<div className={`runway-card reveal${pulse ? " runway-pulse" : ""}`}>
<div className="runway-meter">
<div
className="runway-ring"
style={{ background: `conic-gradient(${level.color} ${pct}%, rgba(255,255,255,0.08) 0)` }}
>
<div className="runway-ring-inner">
<strong>{months >= 999 ? "∞" : months}</strong>
<small>{months >= 999 ? "可持续" : "个月"}</small>
</div>
</div>
<div className="runway-level" style={{ color: level.color }}>
<span>{level.label}</span>
<p>{level.tip}</p>
</div>
</div>
<div className="runway-form">
<label>
<span>启动金 / 存款(¥)</span>
<input
type="number"
min={0}
step={1000}
value={savings}
onChange={(e) => setSavings(Math.max(0, +e.target.value || 0))}
/>
</label>
<label>
<span>月开销(¥)</span>
<input
type="number"
min={0}
step={100}
value={monthly}
onChange={(e) => { setMonthly(Math.max(0, +e.target.value || 0)); setSlug(""); }}
/>
</label>
<label>
<span>月远程收入(¥,可选)</span>
<input
type="number"
min={0}
step={100}
value={income}
onChange={(e) => setIncome(Math.max(0, +e.target.value || 0))}
/>
</label>
<div className="runway-dests">
<small>一键套用目的地月均成本</small>
<div className="runway-dest-list">
{destinations.slice(0, 6).map((d) => (
<button
key={d.slug}
type="button"
className={`runway-dest${slug === d.slug ? " active" : ""}`}
onClick={() => applyDest(d.slug)}
>
<span>{d.emoji}</span>
<em>{d.name}</em>
<small>¥{d.cost.toLocaleString()}</small>
</button>
))}
</div>
</div>
<div className="runway-summary">
<div>
<small>净月消耗</small>
<strong>¥{netBurn.toLocaleString()}</strong>
</div>
<div>
<small>预计支撑</small>
<strong>{months >= 999 ? "收入覆盖开销" : `${months} 个月`}</strong>
</div>
</div>
<div className="runway-milestones" aria-hidden>
{milestones.map((m) => (
<div key={m.m} className={`runway-ms${months >= m.m ? " on" : ""}`}>
<i />
<span>{m.label}</span>
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -3,13 +3,13 @@
import Link from "next/link"; import Link from "next/link";
const QUICK = [ const QUICK = [
{ href: "/#runway", emoji: "🚀", label: "跑道" },
{ href: "/#meetings", emoji: "🤝", label: "会议" },
{ href: "/#trip", emoji: "🗓️", label: "行程" }, { href: "/#trip", emoji: "🗓️", label: "行程" },
{ href: "/#calculator", emoji: "🧮", label: "费用" }, { href: "/#calculator", emoji: "🧮", label: "费用" },
{ href: "/#visa", emoji: "📋", label: "签证" }, { href: "/#visa", emoji: "📋", label: "签证" },
{ href: "/#wifi", emoji: "📶", label: "网速" },
{ href: "/#focus", emoji: "⏱️", label: "专注" }, { href: "/#focus", emoji: "⏱️", label: "专注" },
{ href: "/#expenses", emoji: "📒", label: "账本" }, { href: "/#currency", emoji: "💱", label: "汇率" },
{ href: "/#emergency", emoji: "🆘", label: "紧急" },
{ href: "/tools", emoji: "🛠️", label: "全部" }, { href: "/tools", emoji: "🛠️", label: "全部" },
]; ];

View File

@ -15,11 +15,13 @@ export const TOOL_LINKS: ToolLink[] = [
{ id: "calculator", emoji: "🧮", title: "费用计算器", desc: "按住宿档次估算", href: "/#calculator", category: "money" }, { id: "calculator", emoji: "🧮", title: "费用计算器", desc: "按住宿档次估算", href: "/#calculator", category: "money" },
{ id: "cost-compare", emoji: "💰", title: "省钱对比", desc: "家乡 vs 旅居成本", href: "/#cost-compare", category: "money" }, { id: "cost-compare", emoji: "💰", title: "省钱对比", desc: "家乡 vs 旅居成本", href: "/#cost-compare", category: "money" },
{ id: "savings", emoji: "🎯", title: "启动金目标", desc: "存款进度追踪", href: "/#savings", category: "money" }, { id: "savings", emoji: "🎯", title: "启动金目标", desc: "存款进度追踪", href: "/#savings", category: "money" },
{ id: "runway", emoji: "🚀", title: "旅居跑道", desc: "存款能撑几个月", href: "/#runway", category: "money" },
{ id: "expenses", emoji: "📒", title: "支出账本", desc: "分类记账汇总", href: "/#expenses", category: "money" }, { id: "expenses", emoji: "📒", title: "支出账本", desc: "分类记账汇总", href: "/#expenses", category: "money" },
{ id: "flight", emoji: "✈️", title: "机票估算", desc: "往返票价粗算", href: "/#flight", category: "money" }, { id: "flight", emoji: "✈️", title: "机票估算", desc: "往返票价粗算", href: "/#flight", category: "money" },
{ id: "currency", emoji: "💱", title: "汇率换算", desc: "多币种互转", href: "/#currency", category: "money" }, { id: "currency", emoji: "💱", title: "汇率换算", desc: "多币种互转", href: "/#currency", category: "money" },
{ id: "tax-days", emoji: "🧾", title: "税居天数", desc: "183 天规则追踪", href: "/#tax-days", category: "money" }, { id: "tax-days", emoji: "🧾", title: "税居天数", desc: "183 天规则追踪", href: "/#tax-days", category: "money" },
{ id: "timezone", emoji: "🕐", title: "时区看板", desc: "工作重叠分析", href: "/#timezone", category: "work" }, { id: "timezone", emoji: "🕐", title: "时区看板", desc: "工作重叠分析", href: "/#timezone", category: "work" },
{ id: "meetings", emoji: "🤝", title: "会议黄金时段", desc: "对齐可开会窗口", href: "/#meetings", category: "work" },
{ id: "jetlag", emoji: "😴", title: "时差恢复", desc: "恢复期建议", href: "/#jetlag", category: "work" }, { id: "jetlag", emoji: "😴", title: "时差恢复", desc: "恢复期建议", href: "/#jetlag", category: "work" },
{ id: "wifi", emoji: "📶", title: "网速评级", desc: "远程办公是否够用", href: "/#wifi", category: "work" }, { id: "wifi", emoji: "📶", title: "网速评级", desc: "远程办公是否够用", href: "/#wifi", category: "work" },
{ id: "focus", emoji: "⏱️", title: "专注时钟", desc: "番茄钟深度工作", href: "/#focus", category: "work" }, { id: "focus", emoji: "⏱️", title: "专注时钟", desc: "番茄钟深度工作", href: "/#focus", category: "work" },