Polish discovery funnel: surface plan/compare, login next redirect, SEO metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 04:17:50 -05:00
parent 11daabf065
commit 8ab9b9b9a1
18 changed files with 293 additions and 146 deletions

View File

@ -8,6 +8,16 @@ export const metadata: Metadata = {
};
const LOGS = [
{
date: "2026-08-29",
tag: "产品打磨",
items: [
"Hero / 导航 / 底栏 / 快捷条 / 新手引导统一导向计划与对比",
"登录支持 ?next= 回流,默认进入计划中心并强调云端同步",
"Sitemap 与 OG 补齐 /plan /compare;目的地页补 generateMetadata",
"首页对比直达对比台;行程区收束为计划中心入口",
],
},
{
date: "2026-08-29",
tag: "性能优化",

View File

@ -8,6 +8,17 @@ import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = {
title: "城市对比 · nomadro",
description: "并排对比旅居城市费用、网速、气候与评分,决定后写入旅居计划",
openGraph: {
title: "城市对比台 · nomadro",
description: "最多 4 城并排对比,推荐城一键写入旅居计划",
url: "/compare",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "城市对比台 · nomadro",
description: "最多 4 城并排对比,推荐城一键写入旅居计划",
},
};
export const revalidate = 60;

View File

@ -1,10 +1,30 @@
import Link from "next/link";
import type { Metadata } from "next";
import { api } from "@/lib/api";
import { notFound } from "next/navigation";
import FavoriteButton from "@/components/FavoriteButton";
import DestinationDetailClient from "@/components/DestinationDetailClient";
import SiteShell from "@/components/SiteShell";
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
try {
const dest = await api.getDestination(slug);
const title = `${dest.emoji} ${dest.name}, ${dest.country} · nomadro`;
const description = `${dest.description.slice(0, 120)}… 月费约 ¥${dest.cost.toLocaleString()} · 网速 ${dest.speed}Mbps`;
return {
title,
description,
openGraph: { title, description, url: `/destinations/${slug}` },
twitter: { card: "summary_large_image", title, description },
};
} catch {
return { title: "目的地 · nomadro" };
}
}
export default async function DestinationPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let dest;

View File

@ -9237,3 +9237,58 @@ img { max-width: 100%; display: block; }
color: var(--text-secondary);
line-height: 1.5;
}
/* ===== Product discovery polish ===== */
.trip-hero-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
margin-top: 14px;
}
.auth-sync-note {
margin: 0 0 16px;
padding: 10px 14px;
border-radius: var(--radius-md);
border: var(--border-glass);
background: color-mix(in srgb, var(--accent-2) 10%, transparent);
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
.auth-sync-note a {
color: var(--accent-2);
margin-left: 4px;
}
.tools-core-banner {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 0 0 28px;
}
.tools-core-card {
display: flex;
gap: 12px;
align-items: center;
padding: 16px 18px;
border-radius: var(--radius-xl);
border: var(--border-glass);
background: var(--bg-card);
text-decoration: none;
color: inherit;
transition: border-color 0.2s, transform 0.2s;
}
.tools-core-card:hover {
border-color: var(--accent-2);
transform: translateY(-2px);
}
.tools-core-card span { font-size: 1.6rem; }
.tools-core-card strong { display: block; font-size: 0.95rem; }
.tools-core-card p {
margin: 2px 0 0;
font-size: 0.78rem;
color: var(--text-secondary);
}
@media (max-width: 800px) {
.tools-core-banner { grid-template-columns: 1fr; }
}

View File

@ -1,14 +1,25 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import Link from "next/link";
import SiteShell from "@/components/SiteShell";
export default function LoginPage() {
function safeNext(raw: string | null): string {
if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/plan";
return raw;
}
function LoginForm() {
const { login, register, demoLogin } = useAuth();
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const next = safeNext(searchParams.get("next"));
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@ -17,6 +28,11 @@ export default function LoginPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const goAfterAuth = (msg: string) => {
toast(msg);
setTimeout(() => { router.replace(next); }, 400);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
@ -26,8 +42,11 @@ export default function LoginPage() {
: await register(email, password, name);
setLoading(false);
if (ok) {
toast(mode === "login" ? "欢迎回来!🌍" : "注册成功,开启旅居之旅 ✨");
setTimeout(() => { window.location.href = "/"; }, 600);
goAfterAuth(
mode === "login"
? "欢迎回来!计划已开始同步 🗓️"
: "注册成功,旅居计划可跨设备同步 ✨"
);
} else {
setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在");
}
@ -37,14 +56,10 @@ export default function LoginPage() {
setLoading(true);
const ok = await demoLogin();
setLoading(false);
if (ok) {
toast("演示账号已登录 🎮");
setTimeout(() => { window.location.href = "/"; }, 600);
}
if (ok) goAfterAuth("演示账号已登录 · 正在同步旅居计划 🎮");
};
return (
<SiteShell>
<div className="auth-page">
<div className="auth-bg-shapes" aria-hidden="true">
<span className="auth-shape auth-shape-1">🌍</span>
@ -56,7 +71,16 @@ export default function LoginPage() {
<div className="auth-header">
<span className="auth-logo-emoji">🌍</span>
<h1>{mode === "login" ? "欢迎回来" : "加入 nomadro"}</h1>
<p>{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}</p>
<p>
{mode === "login"
? "登录后同步旅居计划、收藏与就绪清单"
: "注册即可跨设备保存你的旅居计划"}
</p>
</div>
<div className="auth-sync-note">
🗓️ 登录后自动把本机计划与账号合并 · 完成后将前往
<Link href={next}>{next === "/plan" ? "旅居计划中心" : next}</Link>
</div>
<div className="auth-tabs">
@ -93,7 +117,7 @@ export default function LoginPage() {
</div>
{error && <p className="auth-error">{error}</p>}
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
{loading ? <span className="auth-loading">处理中...</span> : (mode === "login" ? "🚀 登录" : "✨ 注册")}
{loading ? <span className="auth-loading">处理中...</span> : (mode === "login" ? "🚀 登录并同步" : "✨ 注册并开始")}
</button>
</form>
@ -101,9 +125,18 @@ export default function LoginPage() {
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}>
🎮 一键体验演示账号
</button>
<p className="auth-hint">演示账号:demo@nomadro.com / demo123</p>
<p className="auth-hint">演示账号:demo@nomadro.com / demo123 · 登录后进入计划中心</p>
</div>
</div>
);
}
export default function LoginPage() {
return (
<SiteShell>
<Suspense fallback={<div className="auth-page"><p className="plan-loading">加载中…</p></div>}>
<LoginForm />
</Suspense>
</SiteShell>
);
}

View File

@ -7,6 +7,17 @@ import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = {
title: "旅居计划中心 · nomadro",
description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单、日历导出——一页做完旅居规划",
openGraph: {
title: "旅居计划中心 · nomadro",
description: "时间轴、签证、预算与就绪清单——真正能执行的旅居计划",
url: "/plan",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "旅居计划中心 · nomadro",
description: "时间轴、签证、预算与就绪清单——真正能执行的旅居计划",
},
};
export const revalidate = 60;

View File

@ -9,10 +9,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${SITE}/plan`, lastModified: now, changeFrequency: "weekly", priority: 0.95 },
{ url: `${SITE}/compare`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
{ url: `${SITE}/tools`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ 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) => ({

View File

@ -38,6 +38,30 @@ export default function ToolsHubPage() {
<p>规划、金钱、工作、生活与安全——一站直达所有旅居工具</p>
</div>
<div className="tools-core-banner">
<Link href="/plan" className="tools-core-card">
<span>🗓️</span>
<div>
<strong>旅居计划中心</strong>
<p>时间轴 · 签证 · 预算 · 日历导出</p>
</div>
</Link>
<Link href="/compare" className="tools-core-card">
<span>⚖️</span>
<div>
<strong>城市对比台</strong>
<p>并排指标 · 一键写入计划</p>
</div>
</Link>
<Link href="/login?next=/plan" className="tools-core-card">
<span>☁️</span>
<div>
<strong>登录同步</strong>
<p>跨设备保存计划与收藏</p>
</div>
</Link>
</div>
<div className="tools-featured">
<div className="tools-featured-head">
<strong>✨ 本周推荐</strong>

View File

@ -40,17 +40,18 @@ export default function Footer() {
<div className="footer-links">
<h4>探索</h4>
<Link href="/#destinations">目的地</Link>
<Link href="/plan">旅居计划</Link>
<Link href="/compare">城市对比</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="/plan">计划中心</Link>
<Link href="/compare">对比台</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>

View File

@ -119,8 +119,8 @@ export default function Hero({ stats }: Props) {
<span>探索目的地</span>
<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>
<a href="/plan" className="btn btn-ghost"><span>🗓️ 旅居计划</span></a>
<a href="/compare" 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>

View File

@ -3,7 +3,6 @@
import { useEffect, useState, type ComponentType } from "react";
import Link from "next/link";
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";
import ParticleCanvas from "./ParticleCanvas";
@ -84,7 +83,6 @@ const BlogSection = lazy(() => import("./BlogSection"));
const FAQSection = lazy(() => import("./FAQSection"));
const Community = lazy(() => import("./Community"));
const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
const CompareModal = lazy(() => import("./CompareModal"));
const CookieConsent = lazy(() => import("./CookieConsent"));
const NomadTips = lazy(() => import("./NomadTips"));
const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
@ -105,8 +103,6 @@ interface Props {
export default function HomeClient(props: Props) {
const [compareList, setCompareList] = useState<string[]>([]);
const [compareResults, setCompareResults] = useState<Destination[] | null>(null);
const [compareOpen, setCompareOpen] = useState(false);
const [matcherOpen, setMatcherOpen] = useState(false);
const [chromeReady, setChromeReady] = useState(false);
@ -163,16 +159,11 @@ export default function HomeClient(props: Props) {
if (prev.length >= 4) return prev;
return [...prev, slug];
});
setCompareResults(null);
};
const runCompare = async () => {
const runCompare = () => {
if (compareList.length < 2) return;
try {
const data = await api.compareDestinations(compareList);
setCompareResults(data);
setCompareOpen(true);
} catch { /* ignore */ }
window.location.href = `/compare?cities=${compareList.join(",")}`;
};
return (
@ -266,7 +257,7 @@ export default function HomeClient(props: Props) {
{compareList.length > 0 && (
<div className="compare-bar">
<span>⚖️ 已选 {compareList.length}/4</span>
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>快速对比</button>
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>打开对比台</button>
<Link
href={compareList.length >= 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
@ -275,13 +266,9 @@ export default function HomeClient(props: Props) {
>
完整对比页
</Link>
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
<button className="btn btn-ghost" onClick={() => setCompareList([])}>清空</button>
</div>
)}
{compareOpen && compareResults && (
<CompareModal destinations={compareResults} onClose={() => setCompareOpen(false)} />
)}
</>
);
}

View File

@ -301,7 +301,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
</button>
</>
) : (
<Link href="/login" className="plan-sync-pill idle">登录后跨设备同步计划 →</Link>
<Link href="/login?next=/plan" className="plan-sync-pill idle">登录后跨设备同步计划 →</Link>
)}
</div>
<div className="plan-meta-row">

View File

@ -9,7 +9,7 @@ const LINKS = [
{ href: "/#map", label: "🗺️ 地图", section: "map" },
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" },
{ href: "/plan", label: "🗓️ 计划", section: "plan" },
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" },
{ href: "/compare", label: "⚖️ 对比", section: "compare" },
{ href: "/tools", label: "🛠️ 工具箱", section: "tools" },
{ href: "/#visa", label: "📋 签证", section: "visa" },
{ href: "/#blog", label: "📝 博客", section: "blog" },
@ -86,7 +86,7 @@ export default function Navbar() {
{LINKS.map((l) => (
<Link key={l.section} href={l.href} data-section={l.section}
className={
l.href === "/tools" || l.href === "/plan"
l.href === "/tools" || l.href === "/plan" || l.href === "/compare"
? (pathname === l.href ? "active" : "")
: (isHome && active === l.section ? "active" : "")
}

View File

@ -1,17 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
const STEPS = [
{ id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏与对比" },
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐最适合你的城市" },
{ id: "trip", emoji: "🗓️", title: "规划行程", desc: "多城串联并估算预算" },
{ id: "tools", emoji: "🛠️", title: "用好工具", desc: "签证、保险、网速与账本" },
{ id: "destinations", emoji: "🌍", title: "探索目的地", desc: "筛选城市、收藏与加入计划", action: "scroll" as const },
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐城市,一键写入计划", action: "matcher" as const },
{ id: "plan", emoji: "🗓️", title: "旅居计划", desc: "时间轴、签证、预算与日历导出", action: "route" as const, href: "/plan" },
{ id: "compare", emoji: "⚖️", title: "城市对比", desc: "并排指标,决定后写入计划", action: "route" as const, href: "/compare" },
];
export default function OnboardingTour() {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(0);
const router = useRouter();
useEffect(() => {
if (localStorage.getItem("nomadro-onboarded")) return;
@ -25,16 +27,21 @@ export default function OnboardingTour() {
};
const next = () => {
const s = STEPS[step];
if (s.action === "matcher") {
window.dispatchEvent(new CustomEvent("open-matcher"));
} else if (s.action === "route" && s.href) {
finish();
router.push(s.href);
return;
} else {
document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" });
}
if (step >= STEPS.length - 1) {
finish();
return;
}
const s = STEPS[step];
if (s.id === "matcher") {
window.dispatchEvent(new CustomEvent("open-matcher"));
} else {
document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" });
}
setStep((n) => n + 1);
};
@ -55,7 +62,7 @@ export default function OnboardingTour() {
))}
</div>
<button className="btn btn-primary btn-sm" onClick={next}>
{step >= STEPS.length - 1 ? "开始探索 🚀" : "下一步 →"}
{step >= STEPS.length - 1 ? "打开对比台 →" : "下一步 →"}
</button>
</div>
</div>

View File

@ -4,13 +4,12 @@ import { useEffect, useState } from "react";
import Link from "next/link";
const PRIMARY = [
{ id: "map", emoji: "🗺️", label: "地图" },
{ id: "destinations", emoji: "🌍", label: "目的地" },
{ id: "trip", emoji: "🗓️", label: "行程" },
{ id: "calculator", emoji: "🧮", label: "费用" },
{ id: "visa", emoji: "📋", label: "签证" },
{ id: "events", emoji: "📅", label: "活动" },
{ id: "blog", emoji: "📝", label: "博客" },
{ id: "map", emoji: "🗺️", label: "地图", type: "scroll" as const },
{ id: "destinations", emoji: "🌍", label: "目的地", type: "scroll" as const },
{ id: "plan", emoji: "🗓️", label: "计划", type: "link" as const, href: "/plan" },
{ id: "compare", emoji: "⚖️", label: "对比", type: "link" as const, href: "/compare" },
{ id: "visa", emoji: "📋", label: "签证", type: "scroll" as const },
{ id: "blog", emoji: "📝", label: "博客", type: "scroll" as const },
];
export default function SectionNav() {
@ -25,7 +24,8 @@ export default function SectionNav() {
}, []);
useEffect(() => {
const sections = PRIMARY.map((s) => document.getElementById(s.id)).filter(Boolean);
const scrollIds = PRIMARY.filter((s) => s.type === "scroll").map((s) => s.id);
const sections = scrollIds.map((id) => document.getElementById(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" }
@ -34,24 +34,33 @@ export default function SectionNav() {
return () => observer.disconnect();
}, []);
const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
};
return (
<nav className={`section-nav${visible ? " visible" : ""}`} aria-label="章节导航">
{PRIMARY.map((s) => (
{PRIMARY.map((s) =>
s.type === "link" ? (
<Link
key={s.id}
href={s.href!}
className="section-nav-dot"
title={s.label}
aria-label={s.label}
>
<span className="section-nav-emoji">{s.emoji}</span>
<span className="section-nav-label">{s.label}</span>
</Link>
) : (
<button
key={s.id}
className={`section-nav-dot${active === s.id ? " active" : ""}`}
onClick={() => scrollTo(s.id)}
onClick={() => document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" })}
aria-label={s.label}
title={s.label}
>
<span className="section-nav-emoji">{s.emoji}</span>
<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>

View File

@ -3,24 +3,24 @@
import Link from "next/link";
const QUICK = [
{ href: "/plan", emoji: "🗓️", label: "计划" },
{ href: "/compare", emoji: "⚖️", label: "对比" },
{ href: "/#destinations", emoji: "🌍", label: "目的地" },
{ href: "/#visa", emoji: "📋", label: "签证" },
{ href: "/tools", emoji: "🛠️", label: "工具箱" },
{ href: "/#weekend", emoji: "🎉", label: "周末" },
{ href: "/#day-plan", emoji: "🧩", label: "日程" },
{ href: "/#bill-split", emoji: "➗", label: "分账" },
{ href: "/#mood", emoji: "🪞", label: "心情" },
{ href: "/#scam-alerts", emoji: "🚨", label: "防坑" },
{ href: "/#deposit-return", emoji: "🔐", label: "押金" },
{ href: "/#airport-transfer", emoji: "🚕", label: "接驳" },
{ href: "/tools", emoji: "🛠️", label: "全部" },
{ href: "/#calculator", emoji: "🧮", label: "费用" },
{ href: "/changelog", emoji: "📜", label: "更新" },
];
export default function ToolsStrip() {
return (
<section className="tools-strip" id="tools-strip" aria-label="快捷工具">
<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>
<strong>⚡ 核心功能</strong>
<Link href="/plan">打开旅居计划 →</Link>
</div>
<div className="tools-strip-list">
{QUICK.map((t) => (

View File

@ -2,14 +2,17 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useToast } from "@/lib/toast";
import { loadTrip, saveTrip } from "@/lib/tripStorage";
import type { Destination, TripItem } from "@/lib/types";
interface Props { destinations: Destination[] }
/** Homepage teaser — full editing lives on /plan */
export default function TripPlanner({ destinations }: Props) {
const { toast } = useToast();
const router = useRouter();
const [trip, setTrip] = useState<TripItem[]>([]);
const [selected, setSelected] = useState("");
const [months, setMonths] = useState(1);
@ -21,16 +24,15 @@ export default function TripPlanner({ destinations }: Props) {
try {
const decoded = JSON.parse(atob(shared)) as TripItem[];
if (Array.isArray(decoded) && decoded.length > 0) {
setTrip(decoded);
saveTrip(decoded);
toast("已导入分享的行程 🗺️");
window.history.replaceState({}, "", window.location.pathname + "#trip");
}
} catch { /* ignore */ }
toast("已导入行程,正在打开计划中心");
router.replace(`/plan?trip=${shared}`);
return;
}
} catch { /* ignore */ }
}
setTrip(loadTrip<TripItem>());
}, [toast]);
}, [toast, router]);
const save = (items: TripItem[]) => {
setTrip(items);
@ -46,66 +48,28 @@ export default function TripPlanner({ destinations }: Props) {
}]);
setSelected("");
setMonths(1);
toast(`${dest.emoji} 已加入 · 完整功能请打开计划中心`);
};
const removeCity = (slug: string) => save(trip.filter((t) => t.slug !== slug));
const updateMonths = (slug: string, m: number) =>
save(trip.map((t) => t.slug === slug ? { ...t, months: m } : t));
const totalCost = trip.reduce((sum, t) => sum + t.cost * t.months, 0);
const totalMonths = trip.reduce((sum, t) => sum + t.months, 0);
const shareTrip = async () => {
const encoded = btoa(JSON.stringify(trip));
const url = `${window.location.origin}/plan?trip=${encoded}`;
await navigator.clipboard.writeText(url);
toast("分享链接已复制!发送给好友即可导入行程 🔗");
};
const exportTrip = () => {
const text = trip.map((t, i) =>
`${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 (¥${(t.cost * t.months).toLocaleString()})`
).join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()}`;
navigator.clipboard.writeText(text);
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">
<div className="section-header reveal">
<span className="section-tag">🗓️ TRIP PLANNER</span>
<h2>行程规划器</h2>
<p>快速搭路线看预算;完整时间轴、签证提醒与出发清单请用计划中心</p>
<Link href="/plan" className="btn btn-primary" style={{ marginTop: 12 }}>
打开旅居计划中心 →
</Link>
<span className="section-tag">🗓️ MOVE PLAN</span>
<h2>旅居计划</h2>
<p>在首页快速加城;签证提醒、就绪清单、日历导出与云端同步请用计划中心</p>
<div className="trip-hero-actions">
<Link href="/plan" className="btn btn-primary">打开旅居计划中心 →</Link>
<Link href="/compare" className="btn btn-ghost">城市对比台</Link>
</div>
</div>
<div className="trip-wrapper reveal">
<div className="trip-form">
<h3>➕ 添加城市</h3>
<h3>➕ 快速添加</h3>
<div className="calc-field">
<label>🏙️ 选择目的地</label>
<select value={selected} onChange={(e) => setSelected(e.target.value)}>
@ -120,7 +84,7 @@ export default function TripPlanner({ destinations }: Props) {
<input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} />
</div>
<button className="btn btn-primary" onClick={addCity} disabled={!selected}>
添加到行程 🚀
添加到计划
</button>
</div>
@ -128,7 +92,8 @@ export default function TripPlanner({ destinations }: Props) {
{trip.length === 0 ? (
<div className="trip-empty">
<span style={{ fontSize: "3rem" }}>🗺️</span>
<p>还没有添加城市,开始规划你的旅程吧</p>
<p>还没有城市。去匹配推荐,或打开计划中心开始。</p>
<Link href="/plan" className="btn btn-ghost" style={{ marginTop: 12 }}>去计划中心 →</Link>
</div>
) : (
<>
@ -143,10 +108,7 @@ export default function TripPlanner({ destinations }: Props) {
<button onClick={() => removeCity(t.slug)} className="trip-remove">✕</button>
</div>
<div className="trip-stop-controls">
<label>停留</label>
<input type="number" min={1} max={24} value={t.months}
onChange={(e) => updateMonths(t.slug, +e.target.value)} />
<span>个月</span>
<span>{t.months} 个月</span>
<span className="trip-stop-cost">¥{(t.cost * t.months).toLocaleString()}</span>
</div>
</div>
@ -166,10 +128,15 @@ export default function TripPlanner({ destinations }: Props) {
<span>🏙️ 城市数</span>
<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>
<Link href="/plan" className="btn btn-primary">继续完善计划 →</Link>
{trip.length >= 2 && (
<Link
href={`/compare?cities=${trip.map((t) => t.slug).slice(0, 4).join(",")}`}
className="btn btn-ghost"
>
对比这些城市
</Link>
)}
</div>
</>
)}

View File

@ -43,6 +43,16 @@ FILES = [
"frontend/src/components/WhenVisible.tsx",
"frontend/src/app/layout.tsx",
"frontend/next.config.ts",
"frontend/src/components/Hero.tsx",
"frontend/src/components/ToolsStrip.tsx",
"frontend/src/components/SectionNav.tsx",
"frontend/src/components/Footer.tsx",
"frontend/src/components/OnboardingTour.tsx",
"frontend/src/components/TripPlanner.tsx",
"frontend/src/app/login/page.tsx",
"frontend/src/app/sitemap.ts",
"frontend/src/app/tools/page.tsx",
"frontend/src/app/destinations/[slug]/page.tsx",
"frontend/src/lib/types.ts",
"frontend/src/lib/planMeta.ts",
"frontend/src/lib/compareScore.ts",