diff --git a/README.md b/README.md index 9529cd3..abe1d81 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ docker compose up -d | 📅 活动日历 | Meetup / 工作坊 / 线上活动 | | 📶 网速评级 | 远程办公 WiFi 评级与排行 | | 🎯 启动金目标 | 存款进度追踪,预估出发时间 | +| 🚀 旅居跑道 | 存款 ÷ 月开销,算出还能飞多久 | +| 🤝 会议黄金时段 | 双时区工作重叠可视化 | | ✨ 今日金句 | 每日游民灵感语录 | | 😴 时差估算 | 时差跨度与恢复期建议 | | 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 | diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index abec5a6..137a479 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,15 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-08-29", + tag: "新工具", + items: [ + "旅居跑道计算器:存款 + 月开销一眼算出还能飞多久", + "会议黄金时段:双时区工作重叠可视化时间轴", + "快捷工具条更新,直达跑道 / 会议 / 汇率", + ], + }, { date: "2026-08-29", tag: "体验优化", @@ -16,6 +25,7 @@ const LOGS = [ "首页常用工具快捷条 + 下方模块懒加载", "行程支持下载 Markdown 文件", "新增饮食饮水注意事项", + "修复生产环境 CSS 解析失败导致的页面异常", ], }, { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 86c3fcb..371c9a7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -7552,3 +7552,323 @@ img { max-width: 100%; display: block; } @media (max-width: 480px) { .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; } +} diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx index 1132e94..174a3b4 100644 --- a/frontend/src/app/tools/page.tsx +++ b/frontend/src/app/tools/page.tsx @@ -5,10 +5,17 @@ import Link from "next/link"; import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools"; import SiteShell from "@/components/SiteShell"; +const FEATURED = ["runway", "meetings", "trip", "calculator", "visa", "focus"]; + export default function ToolsHubPage() { const [filter, setFilter] = useState("all"); const [q, setQ] = useState(""); + const featured = useMemo( + () => FEATURED.map((id) => TOOL_LINKS.find((t) => t.id === id)).filter((t): t is NonNullable => !!t), + [] + ); + const filtered = useMemo(() => { let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((t) => t.category === filter); if (q.trim()) { @@ -31,6 +38,24 @@ export default function ToolsHubPage() {

规划、金钱、工作、生活与安全——一站直达所有旅居工具

+
+
+ ✨ 本周推荐 + 新上线 · 跑道 & 会议窗口 +
+
+ {featured.map((t) => ( + + {t.emoji} +
+

{t.title}

+

{t.desc}

+
+ + ))} +
+
+
import("./TimezoneBoard")); const JetLagEstimator = lazy(() => import("./JetLagEstimator")); const CostCompare = lazy(() => import("./CostCompare")); const SavingsGoal = lazy(() => import("./SavingsGoal")); +const RunwayCalculator = lazy(() => import("./RunwayCalculator")); const ExpenseTracker = lazy(() => import("./ExpenseTracker")); const TaxDayCounter = lazy(() => import("./TaxDayCounter")); const FlightCost = lazy(() => import("./FlightCost")); const CurrencyConverter = lazy(() => import("./CurrencyConverter")); +const MeetingWindow = lazy(() => import("./MeetingWindow")); const WifiWorkScore = lazy(() => import("./WifiWorkScore")); const FocusTimer = lazy(() => import("./FocusTimer")); const NomadScore = lazy(() => import("./NomadScore")); @@ -165,10 +167,12 @@ export default function HomeClient(props: Props) { + + diff --git a/frontend/src/components/MeetingWindow.tsx b/frontend/src/components/MeetingWindow.tsx new file mode 100644 index 0000000..0e9ca06 --- /dev/null +++ b/frontend/src/components/MeetingWindow.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Destination } from "@/lib/types"; + +const TZ_OFFSETS: Record = { + 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 ( +
+
+
+ 🤝 MEETINGS +

会议黄金时段

+

对齐你与目的地的工作时间,一眼找到可开会窗口

+
+ +
+
+ + + + +
+ +
+ {overlapCount}h + {score.label} +

+ {bestWindows.length === 0 + ? "几乎没有重叠,建议以异步沟通为主" + : `建议窗口:${bestWindows.map((w) => `${fmt(w.start)}–${fmt(w.end)}`).join("、")}(以你的时区计)`} +

+
+ +
+
+ 你方工作 + 对方工作 + 重叠黄金 +
+
+ {hours.map((x) => ( +
+ {x.h % 3 === 0 ? x.h : ""} +
+ ))} +
+
+ 你的时区 · 00–23 + {dest?.emoji} 当地此刻对应见色块悬停 +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/RunwayCalculator.tsx b/frontend/src/components/RunwayCalculator.tsx new file mode 100644 index 0000000..4862068 --- /dev/null +++ b/frontend/src/components/RunwayCalculator.tsx @@ -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 ( +
+
+
+ 🚀 RUNWAY +

旅居跑道计算器

+

用存款与月开销,算出你还能自由飞多久

+
+ +
+
+
+
+ {months >= 999 ? "∞" : months} + {months >= 999 ? "可持续" : "个月"} +
+
+
+ {level.label} +

{level.tip}

+
+
+ +
+ + + + +
+ 一键套用目的地月均成本 +
+ {destinations.slice(0, 6).map((d) => ( + + ))} +
+
+ +
+
+ 净月消耗 + ¥{netBurn.toLocaleString()} +
+
+ 预计支撑 + {months >= 999 ? "收入覆盖开销" : `${months} 个月`} +
+
+ +
+ {milestones.map((m) => ( +
= m.m ? " on" : ""}`}> + + {m.label} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/ToolsStrip.tsx b/frontend/src/components/ToolsStrip.tsx index c840187..56ea1a2 100644 --- a/frontend/src/components/ToolsStrip.tsx +++ b/frontend/src/components/ToolsStrip.tsx @@ -3,13 +3,13 @@ import Link from "next/link"; const QUICK = [ + { href: "/#runway", emoji: "🚀", label: "跑道" }, + { href: "/#meetings", emoji: "🤝", label: "会议" }, { href: "/#trip", emoji: "🗓️", label: "行程" }, { href: "/#calculator", emoji: "🧮", label: "费用" }, { href: "/#visa", emoji: "📋", label: "签证" }, - { href: "/#wifi", emoji: "📶", label: "网速" }, { href: "/#focus", emoji: "⏱️", label: "专注" }, - { href: "/#expenses", emoji: "📒", label: "账本" }, - { href: "/#emergency", emoji: "🆘", label: "紧急" }, + { href: "/#currency", emoji: "💱", label: "汇率" }, { href: "/tools", emoji: "🛠️", label: "全部" }, ]; diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts index 1c46b2d..8d5ef32 100644 --- a/frontend/src/lib/tools.ts +++ b/frontend/src/lib/tools.ts @@ -15,11 +15,13 @@ export const TOOL_LINKS: ToolLink[] = [ { id: "calculator", emoji: "🧮", title: "费用计算器", desc: "按住宿档次估算", href: "/#calculator", category: "money" }, { id: "cost-compare", emoji: "💰", title: "省钱对比", desc: "家乡 vs 旅居成本", href: "/#cost-compare", 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: "flight", emoji: "✈️", title: "机票估算", desc: "往返票价粗算", href: "/#flight", category: "money" }, { id: "currency", emoji: "💱", title: "汇率换算", desc: "多币种互转", href: "/#currency", category: "money" }, { id: "tax-days", emoji: "🧾", title: "税居天数", desc: "183 天规则追踪", href: "/#tax-days", category: "money" }, { 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: "wifi", emoji: "📶", title: "网速评级", desc: "远程办公是否够用", href: "/#wifi", category: "work" }, { id: "focus", emoji: "⏱️", title: "专注时钟", desc: "番茄钟深度工作", href: "/#focus", category: "work" },