Add tools hub, changelog, ATM guide, and slim section nav
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
04f8c14ed5
commit
4ab2d7e412
@ -127,6 +127,9 @@ docker compose up -d
|
||||
| 🆘 紧急联系 | 急救、医院、领事馆速查 |
|
||||
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
|
||||
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
|
||||
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
||||
| 📜 更新日志 | `/changelog` 迭代记录 |
|
||||
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
||||
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
||||
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
||||
|
||||
|
||||
76
frontend/src/app/changelog/page.tsx
Normal file
76
frontend/src/app/changelog/page.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "更新日志 · nomadro",
|
||||
description: "nomadro 产品更新与功能迭代记录",
|
||||
};
|
||||
|
||||
const LOGS = [
|
||||
{
|
||||
date: "2026-08-29",
|
||||
tag: "工具箱",
|
||||
items: [
|
||||
"新增 /tools 工具聚合页,按分类快速跳转",
|
||||
"精简首页章节导航,核心入口更清晰",
|
||||
"新增 ATM 取现避坑指南",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-29",
|
||||
tag: "实用工具",
|
||||
items: [
|
||||
"电源插座、紧急联系、城市笔记、专注时钟",
|
||||
"税居天数、到站清单、咖啡馆推荐、新手引导",
|
||||
"保险指南、支出账本、SIM/eSIM 方案",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-28",
|
||||
tag: "品牌",
|
||||
items: [
|
||||
"品牌统一为 nomadro",
|
||||
"命运转盘、机票估算、活动日历、隐私政策",
|
||||
"签证智能匹配、联合办公、行李清单",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<div className="legal-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">← 返回首页</Link>
|
||||
</nav>
|
||||
<article className="legal-content">
|
||||
<span className="section-tag">📜 CHANGELOG</span>
|
||||
<h1>更新日志</h1>
|
||||
<p className="legal-updated">记录 nomadro 的每一次出发</p>
|
||||
|
||||
<div className="changelog-list">
|
||||
{LOGS.map((log) => (
|
||||
<section key={log.date + log.tag} className="changelog-item">
|
||||
<div className="changelog-meta">
|
||||
<time>{log.date}</time>
|
||||
<span className="changelog-tag">{log.tag}</span>
|
||||
</div>
|
||||
<ul>
|
||||
{log.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="about-actions">
|
||||
<Link href="/tools" className="btn btn-primary">打开工具箱 🛠️</Link>
|
||||
<Link href="/about" className="btn btn-ghost">关于 nomadro</Link>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@ -7327,3 +7327,158 @@ img { max-width: 100%; display: block; }
|
||||
}
|
||||
.notes-count { width: 100%; margin-top: 4px; }
|
||||
}
|
||||
|
||||
/* ===== Tools Hub ===== */
|
||||
.tools-hub-page {
|
||||
padding: calc(var(--nav-height) + 40px) 0 80px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tools-hub-page .section-header h1 {
|
||||
font-size: clamp(1.8rem, 4vw, 2.4rem);
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.tools-hub-toolbar {
|
||||
margin: 28px auto 32px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.tools-hub-search {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--bg-card);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tools-hub-search:focus {
|
||||
outline: none;
|
||||
border-color: rgba(78, 205, 196, 0.5);
|
||||
}
|
||||
|
||||
.tools-hub-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tools-hub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tools-hub-card {
|
||||
padding: 20px;
|
||||
background: var(--bg-card);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: transform 0.25s, border-color 0.25s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tools-hub-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(78, 205, 196, 0.35);
|
||||
}
|
||||
|
||||
.tools-hub-emoji { font-size: 1.8rem; margin-bottom: 8px; }
|
||||
.tools-hub-card h3 { font-size: 1.05rem; margin-bottom: 6px; }
|
||||
.tools-hub-card p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
flex: 1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tools-hub-go {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-nav-tools {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px dashed rgba(78, 205, 196, 0.35) !important;
|
||||
}
|
||||
|
||||
/* ===== ATM Guide ===== */
|
||||
.atm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.atm-card {
|
||||
padding: 22px;
|
||||
background: var(--bg-card);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.atm-card:hover { transform: translateY(-3px); }
|
||||
.atm-emoji { font-size: 1.8rem; display: block; margin-bottom: 8px; }
|
||||
.atm-card h4 { font-size: 1.05rem; margin-bottom: 8px; }
|
||||
.atm-card p { font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5; }
|
||||
|
||||
/* ===== Changelog ===== */
|
||||
.changelog-list { margin: 28px 0; }
|
||||
|
||||
.changelog-item {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.changelog-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.changelog-meta time {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.changelog-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 10px;
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: var(--accent-3);
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.changelog-item ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.changelog-item li {
|
||||
position: relative;
|
||||
padding: 6px 0 6px 16px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.changelog-item li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent-3);
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||
{ url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${SITE}/about`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||
{ url: `${SITE}/tools`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||
{ url: `${SITE}/changelog`, lastModified: now, changeFrequency: "weekly", priority: 0.4 },
|
||||
{ url: `${SITE}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.4 },
|
||||
...DESTINATIONS.map((slug) => ({
|
||||
url: `${SITE}/destinations/${slug}`,
|
||||
|
||||
71
frontend/src/app/tools/page.tsx
Normal file
71
frontend/src/app/tools/page.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
|
||||
export default function ToolsHubPage() {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((t) => t.category === filter);
|
||||
if (q.trim()) {
|
||||
const s = q.toLowerCase();
|
||||
list = list.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s));
|
||||
}
|
||||
return list;
|
||||
}, [filter, q]);
|
||||
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<div className="tools-hub-page">
|
||||
<div className="container">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">← 返回首页</Link>
|
||||
</nav>
|
||||
<div className="section-header">
|
||||
<span className="section-tag">🛠️ TOOLKIT</span>
|
||||
<h1>nomadro 工具箱</h1>
|
||||
<p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p>
|
||||
</div>
|
||||
|
||||
<div className="tools-hub-toolbar">
|
||||
<input
|
||||
className="tools-hub-search"
|
||||
placeholder="搜索工具,如「签证」「机票」「笔记」…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<div className="tools-hub-filters">
|
||||
{TOOL_CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`filter-btn${filter === c.key ? " active" : ""}`}
|
||||
onClick={() => setFilter(c.key)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tools-hub-grid">
|
||||
{filtered.map((t) => (
|
||||
<Link key={t.id} href={t.href} className="tools-hub-card">
|
||||
<span className="tools-hub-emoji">{t.emoji}</span>
|
||||
<h3>{t.title}</h3>
|
||||
<p>{t.desc}</p>
|
||||
<span className="tools-hub-go">打开 →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="dest-empty">没有匹配的工具,试试其他关键词</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/AtmCashGuide.tsx
Normal file
89
frontend/src/components/AtmCashGuide.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Destination } from "@/lib/types";
|
||||
|
||||
interface AtmTip {
|
||||
emoji: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const TIPS: Record<string, AtmTip[]> = {
|
||||
bali: [
|
||||
{ emoji: "🏦", title: "优先银行 ATM", detail: "BCA / Mandiri 手续费相对透明,避开独立小黄机。" },
|
||||
{ emoji: "💳", title: "Wise / 实体卡", detail: "尽量用无外汇手续费卡,按需取现。" },
|
||||
{ emoji: "💵", title: "别一次取太多", detail: "现金够用即可,酒店与餐厅多数可刷卡。" },
|
||||
],
|
||||
chiangmai: [
|
||||
{ emoji: "🏧", title: "Aeon / 银行柜员机", detail: "注意屏幕提示的「动态汇率」选项,尽量选拒绝。" },
|
||||
{ emoji: "💱", title: "拒绝 DCC", detail: "出现「用人民币结算?」选 No,用泰铢记账更划算。" },
|
||||
{ emoji: "🧾", title: "保留小票", detail: "便于核对手续费与异常扣款。" },
|
||||
],
|
||||
lisbon: [
|
||||
{ emoji: "🇪🇺", title: "欧元区友好", detail: "多数 ATM 支持国际卡,留意 Multibanco 标识。" },
|
||||
{ emoji: "🚫", title: "拒绝动态货币转换", detail: "选以欧元扣款,避免银行额外加点。" },
|
||||
{ emoji: "🪙", title: "小面额现金", detail: "市场与有轨电车偶尔需要硬币。" },
|
||||
],
|
||||
barcelona: [
|
||||
{ emoji: "🏧", title: "银行网点 ATM", detail: "CaixaBank / Santander 更稳,避开街头独立机。" },
|
||||
{ emoji: "🚫", title: "拒 DCC", detail: "选欧元结算,别让机器帮你换汇。" },
|
||||
{ emoji: "👛", title: "现金备用量", detail: "堂食可刷卡,小摊与夜市准备现金。" },
|
||||
],
|
||||
mexico: [
|
||||
{ emoji: "🏦", title: "BBVA / Santander", detail: "大行 ATM 更安全,白天取现。" },
|
||||
{ emoji: "⚠️", title: "警惕侧录", detail: "检查插卡口是否松动,优先商场内机器。" },
|
||||
{ emoji: "💵", title: "小费文化", detail: "预留小额比索现金给餐厅与快递。" },
|
||||
],
|
||||
tokyo: [
|
||||
{ emoji: "🏪", title: "7-Eleven ATM", detail: "对国际卡最友好,便利店随处可取。" },
|
||||
{ emoji: "💴", title: "日元现金仍重要", detail: "部分居酒屋与神社只收现金。" },
|
||||
{ emoji: "💱", title: "少在机场换汇", detail: "汇率通常较差,落地后便利店 ATM 更优。" },
|
||||
],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
}
|
||||
|
||||
export default function AtmCashGuide({ destinations }: Props) {
|
||||
const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
|
||||
const tips = useMemo(() => TIPS[slug] || TIPS.chiangmai, [slug]);
|
||||
const dest = destinations.find((d) => d.slug === slug);
|
||||
|
||||
return (
|
||||
<section className="section atm-section" id="atm">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">🏧 ATM</span>
|
||||
<h2>取现与换汇避坑</h2>
|
||||
<p>拒 DCC、选对机器,少交「智商税」</p>
|
||||
</div>
|
||||
|
||||
<div className="housing-cities reveal">
|
||||
{destinations.map((d) => (
|
||||
<button
|
||||
key={d.slug}
|
||||
className={`season-city-btn${slug === d.slug ? " active" : ""}`}
|
||||
onClick={() => setSlug(d.slug)}
|
||||
>
|
||||
{d.emoji} {d.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="sim-city-title reveal">{dest?.emoji} {dest?.name}</h3>
|
||||
|
||||
<div className="atm-grid">
|
||||
{tips.map((t, i) => (
|
||||
<div key={t.title} className="atm-card reveal" style={{ transitionDelay: `${i * 0.08}s` }}>
|
||||
<span className="atm-emoji">{t.emoji}</span>
|
||||
<h4>{t.title}</h4>
|
||||
<p>{t.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -40,28 +40,24 @@ export default function Footer() {
|
||||
<div className="footer-links">
|
||||
<h4>探索</h4>
|
||||
<Link href="/#destinations">目的地</Link>
|
||||
<Link href="/#spin">命运转盘</Link>
|
||||
<Link href="/#wifi">网速评级</Link>
|
||||
<Link href="/#cafes">咖啡馆</Link>
|
||||
<Link href="/#emergency">紧急联系</Link>
|
||||
<Link href="/#plugs">电源插座</Link>
|
||||
<Link href="/#notes">城市笔记</Link>
|
||||
<Link href="/#visa">签证指南</Link>
|
||||
<Link href="/#events">活动日历</Link>
|
||||
<Link href="/#blog">博客</Link>
|
||||
<Link href="/about">关于 nomadro</Link>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<h4>工具</h4>
|
||||
<Link href="/tools">全部工具箱</Link>
|
||||
<Link href="/#trip">行程规划</Link>
|
||||
<Link href="/#focus">专注时钟</Link>
|
||||
<Link href="/#expenses">支出账本</Link>
|
||||
<Link href="/#tax-days">税居天数</Link>
|
||||
<Link href="/#calculator">费用计算器</Link>
|
||||
<Link href="/#atm">取现避坑</Link>
|
||||
<Link href="/changelog">更新日志</Link>
|
||||
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<h4>账户</h4>
|
||||
<Link href="/login">登录 / 注册</Link>
|
||||
<Link href="/profile">用户中心</Link>
|
||||
<Link href="/about">关于 nomadro</Link>
|
||||
<Link href="/privacy">隐私政策</Link>
|
||||
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
|
||||
</div>
|
||||
|
||||
@ -120,6 +120,7 @@ export default function Hero({ stats }: Props) {
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||
</a>
|
||||
<a href="#calculator" className="btn btn-ghost"><span>🧮 费用计算</span></a>
|
||||
<a href="/tools" className="btn btn-ghost"><span>🛠️ 工具箱</span></a>
|
||||
<button className="btn btn-ghost hero-matcher-btn" onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent("open-matcher"));
|
||||
}}><span>🎯 智能匹配</span></button>
|
||||
|
||||
@ -55,6 +55,7 @@ import PowerPlugGuide from "./PowerPlugGuide";
|
||||
import EmergencyContacts from "./EmergencyContacts";
|
||||
import CityNotes from "./CityNotes";
|
||||
import FocusTimer from "./FocusTimer";
|
||||
import AtmCashGuide from "./AtmCashGuide";
|
||||
import KeyboardShortcuts from "./KeyboardShortcuts";
|
||||
import BackToTop from "./BackToTop";
|
||||
|
||||
@ -144,6 +145,7 @@ export default function HomeClient(props: Props) {
|
||||
<VisaSection visas={props.visas} />
|
||||
<InsuranceGuide />
|
||||
<EmergencyContacts destinations={props.destinations} />
|
||||
<AtmCashGuide destinations={props.destinations} />
|
||||
<CoworkingGuide />
|
||||
<HousingGuide destinations={props.destinations} />
|
||||
<SimGuide destinations={props.destinations} />
|
||||
|
||||
@ -9,8 +9,8 @@ const LINKS = [
|
||||
{ href: "/#map", label: "🗺️ 地图", section: "map" },
|
||||
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" },
|
||||
{ href: "/#trip", label: "🗓️ 行程", section: "trip" },
|
||||
{ href: "/#timezone", label: "🕐 时区", section: "timezone" },
|
||||
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" },
|
||||
{ href: "/tools", label: "🛠️ 工具箱", section: "tools" },
|
||||
{ href: "/#visa", label: "📋 签证", section: "visa" },
|
||||
{ href: "/#blog", label: "📝 博客", section: "blog" },
|
||||
];
|
||||
@ -41,7 +41,8 @@ export default function Navbar() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHome) return;
|
||||
const sections = LINKS.map((l) => document.getElementById(l.section)).filter(Boolean);
|
||||
const homeLinks = LINKS.filter((l) => l.href.startsWith("/#"));
|
||||
const sections = homeLinks.map((l) => document.getElementById(l.section)).filter(Boolean);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
|
||||
{ threshold: 0.3, rootMargin: "-80px 0px -50% 0px" }
|
||||
@ -84,7 +85,11 @@ export default function Navbar() {
|
||||
<nav className={`nav-links${menuOpen ? " open" : ""}`}>
|
||||
{LINKS.map((l) => (
|
||||
<Link key={l.section} href={l.href} data-section={l.section}
|
||||
className={isHome && active === l.section ? "active" : ""}
|
||||
className={
|
||||
l.href === "/tools"
|
||||
? (pathname === "/tools" ? "active" : "")
|
||||
: (isHome && active === l.section ? "active" : "")
|
||||
}
|
||||
onClick={() => setMenuOpen(false)}>{l.label}</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@ -1,36 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const SECTIONS = [
|
||||
const PRIMARY = [
|
||||
{ id: "map", emoji: "🗺️", label: "地图" },
|
||||
{ id: "destinations", emoji: "🌍", label: "目的地" },
|
||||
{ id: "spin", emoji: "🎲", label: "转盘" },
|
||||
{ id: "timezone", emoji: "🕐", label: "时区" },
|
||||
{ id: "jetlag", emoji: "😴", label: "时差" },
|
||||
{ id: "trip", emoji: "🗓️", label: "行程" },
|
||||
{ id: "calculator", emoji: "🧮", label: "计算器" },
|
||||
{ id: "cost-compare", emoji: "💰", label: "省钱" },
|
||||
{ id: "savings", emoji: "🎯", label: "存款" },
|
||||
{ id: "expenses", emoji: "📒", label: "账本" },
|
||||
{ id: "tax-days", emoji: "🧾", label: "税居" },
|
||||
{ id: "flight", emoji: "✈️", label: "机票" },
|
||||
{ id: "wifi", emoji: "📶", label: "网速" },
|
||||
{ id: "focus", emoji: "⏱️", label: "专注" },
|
||||
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
||||
{ id: "calculator", emoji: "🧮", label: "费用" },
|
||||
{ id: "visa", emoji: "📋", label: "签证" },
|
||||
{ id: "insurance", emoji: "🏥", label: "保险" },
|
||||
{ id: "emergency", emoji: "🆘", label: "紧急" },
|
||||
{ id: "coworking", emoji: "💻", label: "办公" },
|
||||
{ id: "housing", emoji: "🏡", label: "住宿" },
|
||||
{ id: "sim", emoji: "📱", label: "上网" },
|
||||
{ id: "plugs", emoji: "🔌", label: "插座" },
|
||||
{ id: "cafes", emoji: "☕", label: "咖啡" },
|
||||
{ id: "season", emoji: "🌤️", label: "季节" },
|
||||
{ id: "packing", emoji: "🧳", label: "行李" },
|
||||
{ id: "arrival", emoji: "🛬", label: "到站" },
|
||||
{ id: "phrases", emoji: "🗣️", label: "短语" },
|
||||
{ id: "notes", emoji: "📝", label: "笔记" },
|
||||
{ id: "events", emoji: "📅", label: "活动" },
|
||||
{ id: "blog", emoji: "📝", label: "博客" },
|
||||
];
|
||||
@ -47,7 +25,7 @@ export default function SectionNav() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sections = SECTIONS.map((s) => document.getElementById(s.id)).filter(Boolean);
|
||||
const sections = PRIMARY.map((s) => document.getElementById(s.id)).filter(Boolean);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
|
||||
{ threshold: 0.25, rootMargin: "-80px 0px -55% 0px" }
|
||||
@ -62,7 +40,7 @@ export default function SectionNav() {
|
||||
|
||||
return (
|
||||
<nav className={`section-nav${visible ? " visible" : ""}`} aria-label="章节导航">
|
||||
{SECTIONS.map((s) => (
|
||||
{PRIMARY.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`section-nav-dot${active === s.id ? " active" : ""}`}
|
||||
@ -74,6 +52,10 @@ export default function SectionNav() {
|
||||
<span className="section-nav-label">{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<Link href="/tools" className="section-nav-dot section-nav-tools" title="全部工具" aria-label="全部工具">
|
||||
<span className="section-nav-emoji">🛠️</span>
|
||||
<span className="section-nav-label">工具</span>
|
||||
</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
48
frontend/src/lib/tools.ts
Normal file
48
frontend/src/lib/tools.ts
Normal file
@ -0,0 +1,48 @@
|
||||
export interface ToolLink {
|
||||
id: string;
|
||||
emoji: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
href: string;
|
||||
category: "plan" | "money" | "life" | "work" | "safety";
|
||||
}
|
||||
|
||||
export const TOOL_LINKS: ToolLink[] = [
|
||||
{ id: "trip", emoji: "🗓️", title: "行程规划", desc: "多城串联与预算汇总", href: "/#trip", category: "plan" },
|
||||
{ id: "spin", emoji: "🎲", title: "命运转盘", desc: "随机抽下一站", href: "/#spin", category: "plan" },
|
||||
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐目的地", href: "/#destinations", category: "plan" },
|
||||
{ id: "season", emoji: "🌤️", title: "最佳月份", desc: "避开雨季与旺季", href: "/#season", category: "plan" },
|
||||
{ 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: "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: "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" },
|
||||
{ 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: "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" },
|
||||
{ id: "packing", emoji: "🧳", title: "行李清单", desc: "出发前逐项勾选", href: "/#packing", category: "life" },
|
||||
{ id: "arrival", emoji: "🛬", title: "到站清单", desc: "落地第一周", href: "/#arrival", category: "life" },
|
||||
{ id: "phrases", emoji: "🗣️", title: "生存短语", desc: "常用语一键复制", href: "/#phrases", category: "life" },
|
||||
{ id: "notes", emoji: "📝", title: "城市笔记", desc: "本地私密记录", href: "/#notes", category: "life" },
|
||||
{ id: "insurance", emoji: "🏥", title: "保险指南", desc: "国际医疗保险对比", href: "/#insurance", category: "safety" },
|
||||
{ id: "emergency", emoji: "🆘", title: "紧急联系", desc: "急救与领事馆", href: "/#emergency", category: "safety" },
|
||||
{ id: "atm", emoji: "🏧", title: "取现避坑", desc: "ATM 与换汇建议", href: "/#atm", category: "safety" },
|
||||
];
|
||||
|
||||
export const TOOL_CATEGORIES = [
|
||||
{ key: "all", label: "🌏 全部" },
|
||||
{ key: "plan", label: "🗺️ 规划" },
|
||||
{ key: "money", label: "💰 金钱" },
|
||||
{ key: "work", label: "💻 工作" },
|
||||
{ key: "life", label: "🌴 生活" },
|
||||
{ key: "safety", label: "🛡️ 安全" },
|
||||
] as const;
|
||||
Loading…
Reference in New Issue
Block a user