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` 分类搜索全部工具 |
|
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
||||||
| 📜 更新日志 | `/changelog` 迭代记录 |
|
| 📜 更新日志 | `/changelog` 迭代记录 |
|
||||||
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
||||||
|
| 🥗 饮食饮水 | 各城饮水与街头饮食注意 |
|
||||||
|
| ⚡ 工具快捷条 | 首页常用工具一键直达 |
|
||||||
|
| 🔍 搜索增强 | Ctrl+K 可搜工具与页面 |
|
||||||
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
|
||||||
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
| ❓ FAQ | 搜索 + 展开/收起全部 |
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,16 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOGS = [
|
const LOGS = [
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "体验优化",
|
||||||
|
items: [
|
||||||
|
"全局搜索覆盖全部工具与页面",
|
||||||
|
"首页常用工具快捷条 + 下方模块懒加载",
|
||||||
|
"行程支持下载 Markdown 文件",
|
||||||
|
"新增饮食饮水注意事项",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: "2026-08-29",
|
date: "2026-08-29",
|
||||||
tag: "工具箱",
|
tag: "工具箱",
|
||||||
|
|||||||
@ -7482,3 +7482,72 @@ img { max-width: 100%; display: block; }
|
|||||||
left: 0;
|
left: 0;
|
||||||
color: var(--accent-3);
|
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 { useEffect, useState, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { TOOL_LINKS } from "@/lib/tools";
|
||||||
import type { SearchResult } from "@/lib/types";
|
import type { SearchResult } from "@/lib/types";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
@ -10,8 +11,34 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
blog: "博客",
|
blog: "博客",
|
||||||
visa: "签证",
|
visa: "签证",
|
||||||
faq: "FAQ",
|
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() {
|
export default function GlobalSearch() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@ -35,12 +62,15 @@ export default function GlobalSearch() {
|
|||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
if (!q.trim()) { setResults([]); return; }
|
if (!q.trim()) { setResults([]); return; }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
const local = searchLocal(q);
|
||||||
try {
|
try {
|
||||||
const data = await api.search(q);
|
const data = await api.search(q);
|
||||||
setResults(data);
|
const merged = [...local, ...data].slice(0, 20);
|
||||||
|
setResults(merged);
|
||||||
setActiveIdx(0);
|
setActiveIdx(0);
|
||||||
} catch {
|
} catch {
|
||||||
setResults([]);
|
setResults(local);
|
||||||
|
setActiveIdx(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -89,7 +119,7 @@ export default function GlobalSearch() {
|
|||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="搜索目的地、博客、签证..."
|
placeholder="搜索目的地、工具、签证、博客…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@ -102,7 +132,7 @@ export default function GlobalSearch() {
|
|||||||
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
|
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
|
||||||
)}
|
)}
|
||||||
{!loading && results.map((r, i) => (
|
{!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" : ""}`}
|
className={`search-result${i === activeIdx ? " active" : ""}`}
|
||||||
onClick={() => navigate(r.url)}
|
onClick={() => navigate(r.url)}
|
||||||
onMouseEnter={() => setActiveIdx(i)}>
|
onMouseEnter={() => setActiveIdx(i)}>
|
||||||
@ -116,7 +146,7 @@ export default function GlobalSearch() {
|
|||||||
))}
|
))}
|
||||||
{!query && (
|
{!query && (
|
||||||
<div className="search-hints">
|
<div className="search-hints">
|
||||||
<p>💡 试试搜索:清迈、签证、税务</p>
|
<p>💡 试试:清迈、机票、税居、工具箱</p>
|
||||||
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
|
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { api } from "@/lib/api";
|
||||||
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
|
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
|
||||||
import Loader from "./Loader";
|
import Loader from "./Loader";
|
||||||
@ -10,54 +11,60 @@ import TickerBar from "./TickerBar";
|
|||||||
import Hero from "./Hero";
|
import Hero from "./Hero";
|
||||||
import WorldMap from "./WorldMap";
|
import WorldMap from "./WorldMap";
|
||||||
import Destinations from "./Destinations";
|
import Destinations from "./Destinations";
|
||||||
import Calculator from "./Calculator";
|
|
||||||
import TripPlanner from "./TripPlanner";
|
|
||||||
import GlobalSearch from "./GlobalSearch";
|
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 Footer from "./Footer";
|
||||||
import CompareModal from "./CompareModal";
|
import CompareModal from "./CompareModal";
|
||||||
import DestinationMatcher, { MatcherTrigger } from "./DestinationMatcher";
|
import DestinationMatcher, { MatcherTrigger } from "./DestinationMatcher";
|
||||||
import TimezoneBoard from "./TimezoneBoard";
|
|
||||||
import ScrollProgress from "./ScrollProgress";
|
import ScrollProgress from "./ScrollProgress";
|
||||||
import SectionNav from "./SectionNav";
|
import SectionNav from "./SectionNav";
|
||||||
import CurrencyConverter from "./CurrencyConverter";
|
|
||||||
import NomadScore from "./NomadScore";
|
|
||||||
import CostCompare from "./CostCompare";
|
|
||||||
import CookieConsent from "./CookieConsent";
|
import CookieConsent from "./CookieConsent";
|
||||||
import CoworkingGuide from "./CoworkingGuide";
|
|
||||||
import PackingChecklist from "./PackingChecklist";
|
|
||||||
import SeasonGuide from "./SeasonGuide";
|
|
||||||
import NomadTips from "./NomadTips";
|
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 DailyQuote from "./DailyQuote";
|
||||||
import JetLagEstimator from "./JetLagEstimator";
|
import ToolsStrip from "./ToolsStrip";
|
||||||
import HousingGuide from "./HousingGuide";
|
|
||||||
import Phrasebook from "./Phrasebook";
|
|
||||||
import FeedbackWidget from "./FeedbackWidget";
|
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 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 KeyboardShortcuts from "./KeyboardShortcuts";
|
||||||
import BackToTop from "./BackToTop";
|
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 {
|
interface Props {
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
@ -84,16 +91,30 @@ export default function HomeClient(props: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reveals = document.querySelectorAll(".reveal");
|
|
||||||
const observer = new IntersectionObserver(
|
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" }
|
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" }
|
||||||
);
|
);
|
||||||
reveals.forEach((el, i) => {
|
|
||||||
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.1}s`;
|
const observeAll = () => {
|
||||||
observer.observe(el);
|
document.querySelectorAll(".reveal:not(.visible)").forEach((el, i) => {
|
||||||
});
|
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.1}s`;
|
||||||
return () => observer.disconnect();
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
observeAll();
|
||||||
|
const mo = new MutationObserver(observeAll);
|
||||||
|
mo.observe(document.body, { childList: true, subtree: true });
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
mo.disconnect();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleCompare = (slug: string) => {
|
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)} />
|
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
|
||||||
<SpinGlobe destinations={props.destinations} />
|
<SpinGlobe destinations={props.destinations} />
|
||||||
<DailyQuote />
|
<DailyQuote />
|
||||||
|
<ToolsStrip />
|
||||||
<TimezoneBoard destinations={props.destinations} />
|
<TimezoneBoard destinations={props.destinations} />
|
||||||
<JetLagEstimator destinations={props.destinations} />
|
<JetLagEstimator destinations={props.destinations} />
|
||||||
<TripPlanner destinations={props.destinations} />
|
<TripPlanner destinations={props.destinations} />
|
||||||
@ -146,6 +168,7 @@ export default function HomeClient(props: Props) {
|
|||||||
<InsuranceGuide />
|
<InsuranceGuide />
|
||||||
<EmergencyContacts destinations={props.destinations} />
|
<EmergencyContacts destinations={props.destinations} />
|
||||||
<AtmCashGuide destinations={props.destinations} />
|
<AtmCashGuide destinations={props.destinations} />
|
||||||
|
<FoodWaterTips destinations={props.destinations} />
|
||||||
<CoworkingGuide />
|
<CoworkingGuide />
|
||||||
<HousingGuide destinations={props.destinations} />
|
<HousingGuide destinations={props.destinations} />
|
||||||
<SimGuide destinations={props.destinations} />
|
<SimGuide destinations={props.destinations} />
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const SHORTCUTS = [
|
|||||||
{ keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
|
{ keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
|
||||||
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
|
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
|
||||||
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
|
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
|
||||||
|
{ keys: ["T"], label: "打开工具箱", icon: "🛠️" },
|
||||||
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
|
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
|
||||||
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
|
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
|
||||||
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
|
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
|
||||||
@ -36,6 +37,10 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.getElementById("spin")?.scrollIntoView({ behavior: "smooth" });
|
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);
|
if (e.key === "Escape" && open) setOpen(false);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
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("行程已复制到剪贴板 📋");
|
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 (
|
return (
|
||||||
<section className="section trip-section" id="trip">
|
<section className="section trip-section" id="trip">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -141,6 +163,7 @@ export default function TripPlanner({ destinations }: Props) {
|
|||||||
<strong>{trip.length} 个</strong>
|
<strong>{trip.length} 个</strong>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-ghost" onClick={exportTrip}>📋 复制行程</button>
|
<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={shareTrip}>🔗 分享行程</button>
|
||||||
<button className="btn btn-ghost" onClick={() => save([])}>🗑️ 清空</button>
|
<button className="btn btn-ghost" onClick={() => save([])}>🗑️ 清空</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export const TOOL_LINKS: ToolLink[] = [
|
|||||||
{ id: "insurance", emoji: "🏥", title: "保险指南", desc: "国际医疗保险对比", href: "/#insurance", category: "safety" },
|
{ id: "insurance", emoji: "🏥", title: "保险指南", desc: "国际医疗保险对比", href: "/#insurance", category: "safety" },
|
||||||
{ id: "emergency", emoji: "🆘", title: "紧急联系", desc: "急救与领事馆", href: "/#emergency", category: "safety" },
|
{ id: "emergency", emoji: "🆘", title: "紧急联系", desc: "急救与领事馆", href: "/#emergency", category: "safety" },
|
||||||
{ id: "atm", emoji: "🏧", title: "取现避坑", desc: "ATM 与换汇建议", href: "/#atm", 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 = [
|
export const TOOL_CATEGORIES = [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user