Improve search, tools strip, trip MD export, lazy-load home sections
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4ab2d7e412
commit
96d3d83906
@ -130,6 +130,9 @@ docker compose up -d
|
||||
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
||||
| 📜 更新日志 | `/changelog` 迭代记录 |
|
||||
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
||||
| 🥗 饮食饮水 | 各城饮水与街头饮食注意 |
|
||||
| ⚡ 工具快捷条 | 首页常用工具一键直达 |
|
||||
| 🔍 搜索增强 | Ctrl+K 可搜工具与页面 |
|
||||
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
||||
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
||||
|
||||
|
||||
@ -8,6 +8,16 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
const LOGS = [
|
||||
{
|
||||
date: "2026-08-29",
|
||||
tag: "体验优化",
|
||||
items: [
|
||||
"全局搜索覆盖全部工具与页面",
|
||||
"首页常用工具快捷条 + 下方模块懒加载",
|
||||
"行程支持下载 Markdown 文件",
|
||||
"新增饮食饮水注意事项",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-08-29",
|
||||
tag: "工具箱",
|
||||
|
||||
@ -7482,3 +7482,72 @@ img { max-width: 100%; display: block; }
|
||||
left: 0;
|
||||
color: var(--accent-3);
|
||||
}
|
||||
|
||||
/* ===== Tools Strip ===== */
|
||||
.tools-strip {
|
||||
padding: 12px 0 28px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tools-strip-inner {
|
||||
padding: 18px 20px;
|
||||
background: var(--bg-card);
|
||||
border: var(--border-glass);
|
||||
border-radius: var(--radius-xl);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.tools-strip-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tools-strip-head strong { font-size: 0.95rem; }
|
||||
.tools-strip-head a {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tools-strip-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tools-strip-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 6px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tools-strip-item:hover {
|
||||
border-color: rgba(78, 205, 196, 0.35);
|
||||
background: rgba(78, 205, 196, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tools-strip-item span { font-size: 1.3rem; }
|
||||
.tools-strip-item small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tools-strip-list { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tools-strip-list { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
|
||||
89
frontend/src/components/FoodWaterTips.tsx
Normal file
89
frontend/src/components/FoodWaterTips.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Destination } from "@/lib/types";
|
||||
|
||||
interface Tip {
|
||||
emoji: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const TIPS: Record<string, Tip[]> = {
|
||||
bali: [
|
||||
{ emoji: "💧", title: "别喝自来水", detail: "买瓶装水或用滤水壶,刷牙也建议用瓶装水。" },
|
||||
{ emoji: "🌶️", title: "街头小吃循序渐进", detail: "选人多翻台快的摊位,第一周少碰生冷。" },
|
||||
{ emoji: "🧊", title: "冰块看场合", detail: "正规餐厅一般安全,路边摊可要求无冰。" },
|
||||
],
|
||||
chiangmai: [
|
||||
{ emoji: "💧", title: "便利店饮水够用", detail: "7-Eleven 瓶装水便宜,也可买滤芯壶。" },
|
||||
{ emoji: "🍜", title: "夜市黄金法则", detail: "跟当地人排队,现做现卖更安心。" },
|
||||
{ emoji: "🥭", title: "水果现切注意卫生", detail: "优先自己削皮的水果,少买路边预切片。" },
|
||||
],
|
||||
lisbon: [
|
||||
{ emoji: "🚰", title: "自来水可直饮", detail: "市区自来水达标,仍可按习惯买瓶装。" },
|
||||
{ emoji: "🐟", title: "海鲜选新鲜时段", detail: "午餐海鲜更稳,注意过敏与季节。" },
|
||||
{ emoji: "🍷", title: "餐饮卫生标准高", detail: "正规餐厅普遍放心,注意小费习惯即可。" },
|
||||
],
|
||||
barcelona: [
|
||||
{ emoji: "🚰", title: "自来水可饮用", detail: "口感偏硬,很多人选瓶装改善口感。" },
|
||||
{ emoji: "🥘", title: "海鲜饭别贪便宜", detail: "选评价好的店,避开港口旁拉客摊。" },
|
||||
{ emoji: "🍦", title: "夏日注意冷链", detail: "冰激凌与生食优先人气店铺。" },
|
||||
],
|
||||
mexico: [
|
||||
{ emoji: "💧", title: "只喝瓶装水", detail: "刷牙、洗水果也建议用瓶装或过滤水。" },
|
||||
{ emoji: "🌮", title: "街头 taco 看人气", detail: "现做、翻台快、有洗手点更佳。" },
|
||||
{ emoji: "🍋", title: "青柠与辣椒循序", detail: "肠胃适应期少叠加过辣与酒精。" },
|
||||
],
|
||||
tokyo: [
|
||||
{ emoji: "🚰", title: "自来水可直饮", detail: "水质优秀,便利店水也很方便。" },
|
||||
{ emoji: "🍣", title: "生食选可信店铺", detail: "回转寿司连锁通常安全,街边需谨慎。" },
|
||||
{ emoji: "🍱", title: "便利店食品放心", detail: "标注清晰,适合赶火车时的稳妥选择。" },
|
||||
],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
}
|
||||
|
||||
export default function FoodWaterTips({ destinations }: Props) {
|
||||
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
|
||||
const tips = useMemo(() => TIPS[slug] || TIPS.bali, [slug]);
|
||||
const dest = destinations.find((d) => d.slug === slug);
|
||||
|
||||
return (
|
||||
<section className="section food-section" id="food-water">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">🥗 FOOD & WATER</span>
|
||||
<h2>饮食饮水注意</h2>
|
||||
<p>落地少踩坑,肠胃稳定才能稳定写代码</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>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { TOOL_LINKS } from "@/lib/tools";
|
||||
import type { SearchResult } from "@/lib/types";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@ -10,8 +11,34 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
blog: "博客",
|
||||
visa: "签证",
|
||||
faq: "FAQ",
|
||||
tool: "工具",
|
||||
page: "页面",
|
||||
};
|
||||
|
||||
const PAGE_RESULTS: SearchResult[] = [
|
||||
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
|
||||
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
|
||||
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
|
||||
{ type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
|
||||
];
|
||||
|
||||
function searchLocal(q: string): SearchResult[] {
|
||||
const s = q.toLowerCase();
|
||||
const tools = TOOL_LINKS
|
||||
.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
|
||||
.map((t) => ({
|
||||
type: "tool",
|
||||
title: t.title,
|
||||
subtitle: t.desc,
|
||||
emoji: t.emoji,
|
||||
url: t.href,
|
||||
}));
|
||||
const pages = PAGE_RESULTS.filter(
|
||||
(p) => p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s)
|
||||
);
|
||||
return [...tools, ...pages];
|
||||
}
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@ -35,12 +62,15 @@ export default function GlobalSearch() {
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) { setResults([]); return; }
|
||||
setLoading(true);
|
||||
const local = searchLocal(q);
|
||||
try {
|
||||
const data = await api.search(q);
|
||||
setResults(data);
|
||||
const merged = [...local, ...data].slice(0, 20);
|
||||
setResults(merged);
|
||||
setActiveIdx(0);
|
||||
} catch {
|
||||
setResults([]);
|
||||
setResults(local);
|
||||
setActiveIdx(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -89,7 +119,7 @@ export default function GlobalSearch() {
|
||||
</svg>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="搜索目的地、博客、签证..."
|
||||
placeholder="搜索目的地、工具、签证、博客…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
@ -102,7 +132,7 @@ export default function GlobalSearch() {
|
||||
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
|
||||
)}
|
||||
{!loading && results.map((r, i) => (
|
||||
<button key={`${r.type}-${r.url}`}
|
||||
<button key={`${r.type}-${r.url}-${r.title}`}
|
||||
className={`search-result${i === activeIdx ? " active" : ""}`}
|
||||
onClick={() => navigate(r.url)}
|
||||
onMouseEnter={() => setActiveIdx(i)}>
|
||||
@ -116,7 +146,7 @@ export default function GlobalSearch() {
|
||||
))}
|
||||
{!query && (
|
||||
<div className="search-hints">
|
||||
<p>💡 试试搜索:清迈、签证、税务</p>
|
||||
<p>💡 试试:清迈、机票、税居、工具箱</p>
|
||||
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type ComponentType } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
|
||||
import Loader from "./Loader";
|
||||
@ -10,54 +11,60 @@ import TickerBar from "./TickerBar";
|
||||
import Hero from "./Hero";
|
||||
import WorldMap from "./WorldMap";
|
||||
import Destinations from "./Destinations";
|
||||
import Calculator from "./Calculator";
|
||||
import TripPlanner from "./TripPlanner";
|
||||
import GlobalSearch from "./GlobalSearch";
|
||||
import Lifestyle from "./Lifestyle";
|
||||
import Charts from "./Charts";
|
||||
import VisaSection from "./VisaSection";
|
||||
import Tools from "./Tools";
|
||||
import BlogSection from "./BlogSection";
|
||||
import FAQSection from "./FAQSection";
|
||||
import Community from "./Community";
|
||||
import Footer from "./Footer";
|
||||
import CompareModal from "./CompareModal";
|
||||
import DestinationMatcher, { MatcherTrigger } from "./DestinationMatcher";
|
||||
import TimezoneBoard from "./TimezoneBoard";
|
||||
import ScrollProgress from "./ScrollProgress";
|
||||
import SectionNav from "./SectionNav";
|
||||
import CurrencyConverter from "./CurrencyConverter";
|
||||
import NomadScore from "./NomadScore";
|
||||
import CostCompare from "./CostCompare";
|
||||
import CookieConsent from "./CookieConsent";
|
||||
import CoworkingGuide from "./CoworkingGuide";
|
||||
import PackingChecklist from "./PackingChecklist";
|
||||
import SeasonGuide from "./SeasonGuide";
|
||||
import NomadTips from "./NomadTips";
|
||||
import SpinGlobe from "./SpinGlobe";
|
||||
import FlightCost from "./FlightCost";
|
||||
import NomadEvents from "./NomadEvents";
|
||||
import WifiWorkScore from "./WifiWorkScore";
|
||||
import SavingsGoal from "./SavingsGoal";
|
||||
import DailyQuote from "./DailyQuote";
|
||||
import JetLagEstimator from "./JetLagEstimator";
|
||||
import HousingGuide from "./HousingGuide";
|
||||
import Phrasebook from "./Phrasebook";
|
||||
import ToolsStrip from "./ToolsStrip";
|
||||
import FeedbackWidget from "./FeedbackWidget";
|
||||
import InsuranceGuide from "./InsuranceGuide";
|
||||
import ExpenseTracker from "./ExpenseTracker";
|
||||
import SimGuide from "./SimGuide";
|
||||
import TaxDayCounter from "./TaxDayCounter";
|
||||
import ArrivalChecklist from "./ArrivalChecklist";
|
||||
import CafeGuide from "./CafeGuide";
|
||||
import OnboardingTour from "./OnboardingTour";
|
||||
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";
|
||||
import TripPlanner from "./TripPlanner";
|
||||
import Calculator from "./Calculator";
|
||||
import SpinGlobe from "./SpinGlobe";
|
||||
|
||||
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
|
||||
dynamic(loader, { ssr: false, loading: () => null });
|
||||
|
||||
const TimezoneBoard = lazy(() => import("./TimezoneBoard"));
|
||||
const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
|
||||
const CostCompare = lazy(() => import("./CostCompare"));
|
||||
const SavingsGoal = lazy(() => import("./SavingsGoal"));
|
||||
const ExpenseTracker = lazy(() => import("./ExpenseTracker"));
|
||||
const TaxDayCounter = lazy(() => import("./TaxDayCounter"));
|
||||
const FlightCost = lazy(() => import("./FlightCost"));
|
||||
const CurrencyConverter = lazy(() => import("./CurrencyConverter"));
|
||||
const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
|
||||
const FocusTimer = lazy(() => import("./FocusTimer"));
|
||||
const NomadScore = lazy(() => import("./NomadScore"));
|
||||
const Lifestyle = lazy(() => import("./Lifestyle"));
|
||||
const Charts = lazy(() => import("./Charts"));
|
||||
const VisaSection = lazy(() => import("./VisaSection"));
|
||||
const InsuranceGuide = lazy(() => import("./InsuranceGuide"));
|
||||
const EmergencyContacts = lazy(() => import("./EmergencyContacts"));
|
||||
const AtmCashGuide = lazy(() => import("./AtmCashGuide"));
|
||||
const FoodWaterTips = lazy(() => import("./FoodWaterTips"));
|
||||
const CoworkingGuide = lazy(() => import("./CoworkingGuide"));
|
||||
const HousingGuide = lazy(() => import("./HousingGuide"));
|
||||
const SimGuide = lazy(() => import("./SimGuide"));
|
||||
const PowerPlugGuide = lazy(() => import("./PowerPlugGuide"));
|
||||
const CafeGuide = lazy(() => import("./CafeGuide"));
|
||||
const SeasonGuide = lazy(() => import("./SeasonGuide"));
|
||||
const PackingChecklist = lazy(() => import("./PackingChecklist"));
|
||||
const ArrivalChecklist = lazy(() => import("./ArrivalChecklist"));
|
||||
const Phrasebook = lazy(() => import("./Phrasebook"));
|
||||
const CityNotes = lazy(() => import("./CityNotes"));
|
||||
const NomadEvents = lazy(() => import("./NomadEvents"));
|
||||
const Tools = lazy(() => import("./Tools"));
|
||||
const BlogSection = lazy(() => import("./BlogSection"));
|
||||
const FAQSection = lazy(() => import("./FAQSection"));
|
||||
const Community = lazy(() => import("./Community"));
|
||||
|
||||
interface Props {
|
||||
stats: Stats;
|
||||
@ -84,16 +91,30 @@ export default function HomeClient(props: Props) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const reveals = document.querySelectorAll(".reveal");
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => entries.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("visible"); observer.unobserve(e.target); } }),
|
||||
(entries) => entries.forEach((e) => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("visible");
|
||||
observer.unobserve(e.target);
|
||||
}
|
||||
}),
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" }
|
||||
);
|
||||
reveals.forEach((el, i) => {
|
||||
|
||||
const observeAll = () => {
|
||||
document.querySelectorAll(".reveal:not(.visible)").forEach((el, i) => {
|
||||
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.1}s`;
|
||||
observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
};
|
||||
|
||||
observeAll();
|
||||
const mo = new MutationObserver(observeAll);
|
||||
mo.observe(document.body, { childList: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleCompare = (slug: string) => {
|
||||
@ -127,6 +148,7 @@ export default function HomeClient(props: Props) {
|
||||
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
|
||||
<SpinGlobe destinations={props.destinations} />
|
||||
<DailyQuote />
|
||||
<ToolsStrip />
|
||||
<TimezoneBoard destinations={props.destinations} />
|
||||
<JetLagEstimator destinations={props.destinations} />
|
||||
<TripPlanner destinations={props.destinations} />
|
||||
@ -146,6 +168,7 @@ export default function HomeClient(props: Props) {
|
||||
<InsuranceGuide />
|
||||
<EmergencyContacts destinations={props.destinations} />
|
||||
<AtmCashGuide destinations={props.destinations} />
|
||||
<FoodWaterTips destinations={props.destinations} />
|
||||
<CoworkingGuide />
|
||||
<HousingGuide destinations={props.destinations} />
|
||||
<SimGuide destinations={props.destinations} />
|
||||
|
||||
@ -10,6 +10,7 @@ const SHORTCUTS = [
|
||||
{ keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
|
||||
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
|
||||
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
|
||||
{ keys: ["T"], label: "打开工具箱", icon: "🛠️" },
|
||||
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
|
||||
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
|
||||
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
|
||||
@ -36,6 +37,10 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
|
||||
e.preventDefault();
|
||||
document.getElementById("spin")?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
if (e.key === "t" && !typing && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.location.href = "/tools";
|
||||
}
|
||||
if (e.key === "Escape" && open) setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
37
frontend/src/components/ToolsStrip.tsx
Normal file
37
frontend/src/components/ToolsStrip.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
const QUICK = [
|
||||
{ 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: "/tools", emoji: "🛠️", label: "全部" },
|
||||
];
|
||||
|
||||
export default function ToolsStrip() {
|
||||
return (
|
||||
<section className="tools-strip" id="tools-strip" aria-label="快捷工具">
|
||||
<div className="container">
|
||||
<div className="tools-strip-inner reveal">
|
||||
<div className="tools-strip-head">
|
||||
<strong>⚡ 常用工具</strong>
|
||||
<Link href="/tools">打开工具箱 →</Link>
|
||||
</div>
|
||||
<div className="tools-strip-list">
|
||||
{QUICK.map((t) => (
|
||||
<Link key={t.href + t.label} href={t.href} className="tools-strip-item">
|
||||
<span>{t.emoji}</span>
|
||||
<small>{t.label}</small>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -69,6 +69,28 @@ export default function TripPlanner({ destinations }: Props) {
|
||||
toast("行程已复制到剪贴板 📋");
|
||||
};
|
||||
|
||||
const downloadTrip = () => {
|
||||
const lines = [
|
||||
"# nomadro 旅居行程",
|
||||
"",
|
||||
...trip.map((t, i) =>
|
||||
`${i + 1}. ${t.emoji} **${t.name}, ${t.country}** — ${t.months} 个月 · ¥${(t.cost * t.months).toLocaleString()}`
|
||||
),
|
||||
"",
|
||||
`> 总计 **${totalMonths} 个月** · **¥${totalCost.toLocaleString()}**`,
|
||||
"",
|
||||
`_由 nomadro 生成 · ${new Date().toLocaleDateString("zh-CN")}_`,
|
||||
];
|
||||
const blob = new Blob([lines.join("\n")], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `nomadro-trip-${Date.now()}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast("已下载 Markdown 行程文件 📄");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section trip-section" id="trip">
|
||||
<div className="container">
|
||||
@ -141,6 +163,7 @@ export default function TripPlanner({ destinations }: Props) {
|
||||
<strong>{trip.length} 个</strong>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={exportTrip}>📋 复制行程</button>
|
||||
<button className="btn btn-ghost" onClick={downloadTrip}>📄 下载 MD</button>
|
||||
<button className="btn btn-ghost" onClick={shareTrip}>🔗 分享行程</button>
|
||||
<button className="btn btn-ghost" onClick={() => save([])}>🗑️ 清空</button>
|
||||
</div>
|
||||
|
||||
@ -36,6 +36,7 @@ export const TOOL_LINKS: ToolLink[] = [
|
||||
{ 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" },
|
||||
{ id: "food-water", emoji: "🥗", title: "饮食饮水", desc: "落地肠胃与饮水注意", href: "/#food-water", category: "safety" },
|
||||
];
|
||||
|
||||
export const TOOL_CATEGORIES = [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user