Add runway calculator and meeting golden-hour timeline with polished UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2dd448874e
commit
b4f1846d65
@ -110,6 +110,8 @@ docker compose up -d
|
||||
| 📅 活动日历 | Meetup / 工作坊 / 线上活动 |
|
||||
| 📶 网速评级 | 远程办公 WiFi 评级与排行 |
|
||||
| 🎯 启动金目标 | 存款进度追踪,预估出发时间 |
|
||||
| 🚀 旅居跑道 | 存款 ÷ 月开销,算出还能飞多久 |
|
||||
| 🤝 会议黄金时段 | 双时区工作重叠可视化 |
|
||||
| ✨ 今日金句 | 每日游民灵感语录 |
|
||||
| 😴 时差估算 | 时差跨度与恢复期建议 |
|
||||
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |
|
||||
|
||||
@ -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 解析失败导致的页面异常",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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<typeof t> => !!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() {
|
||||
<p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p>
|
||||
</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">
|
||||
<input
|
||||
className="tools-hub-search"
|
||||
|
||||
@ -36,10 +36,12 @@ const TimezoneBoard = lazy(() => 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) {
|
||||
<Calculator destinations={props.destinations} />
|
||||
<CostCompare destinations={props.destinations} />
|
||||
<SavingsGoal destinations={props.destinations} />
|
||||
<RunwayCalculator destinations={props.destinations} />
|
||||
<ExpenseTracker />
|
||||
<TaxDayCounter />
|
||||
<FlightCost destinations={props.destinations} />
|
||||
<CurrencyConverter />
|
||||
<MeetingWindow destinations={props.destinations} />
|
||||
<WifiWorkScore destinations={props.destinations} />
|
||||
<FocusTimer />
|
||||
<NomadScore />
|
||||
|
||||
166
frontend/src/components/MeetingWindow.tsx
Normal file
166
frontend/src/components/MeetingWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
frontend/src/components/RunwayCalculator.tsx
Normal file
175
frontend/src/components/RunwayCalculator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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: "全部" },
|
||||
];
|
||||
|
||||
|
||||
@ -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" },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user