Close discover-decide-plan loop: matcher to trip, shareable /compare hub.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 02:58:40 -05:00
parent b6df330a18
commit fd87f765c8
14 changed files with 816 additions and 101 deletions

View File

@ -137,6 +137,7 @@ docker compose up -d
| ⏱️ 专注时钟 | 番茄钟深度工作计时 | | ⏱️ 专注时钟 | 番茄钟深度工作计时 |
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 | | 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 | | 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 |
| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 |
| 📜 更新日志 | `/changelog` 迭代记录 | | 📜 更新日志 | `/changelog` 迭代记录 |
| 🏧 取现避坑 | ATM / DCC / 换汇建议 | | 🏧 取现避坑 | ATM / DCC / 换汇建议 |
| 🥗 饮食饮水 | 各城饮水与街头饮食注意 | | 🥗 饮食饮水 | 各城饮水与街头饮食注意 |
@ -196,6 +197,7 @@ docker compose up -d
| `/profile` | 用户中心 | | `/profile` | 用户中心 |
| `/destinations/[slug]` | 目的地详情(城市画像、加入计划) | | `/destinations/[slug]` | 目的地详情(城市画像、加入计划) |
| `/plan` | 旅居计划中心(时间轴 / 预算 / 清单 / 分享) | | `/plan` | 旅居计划中心(时间轴 / 预算 / 清单 / 分享) |
| `/compare` | 城市对比台(可分享 URL → 写入计划) |
| `/tools` | 工具箱聚合 | | `/tools` | 工具箱聚合 |
| `/blog/[slug]` | 博客详情(阅读进度) | | `/blog/[slug]` | 博客详情(阅读进度) |

View File

@ -8,6 +8,15 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-08-29",
tag: "决策闭环",
items: [
"智能匹配:偏好本地保存;Top 3 一键写入旅居计划;单城「+ 计划」;对比 Top 结果",
"全新「城市对比台」/compare:可分享 URL、并排指标、推荐城/全部写入计划",
"首页对比条与计划中心可跳转完整对比页",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "核心功能", tag: "核心功能",

View File

@ -0,0 +1,30 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { api } from "@/lib/api";
import type { Destination } from "@/lib/types";
import CompareClient from "@/components/CompareClient";
import SiteShell from "@/components/SiteShell";
export const metadata: Metadata = {
title: "城市对比 · nomadro",
description: "并排对比旅居城市费用、网速、气候与评分,决定后写入旅居计划",
};
export const revalidate = 60;
export default async function ComparePage() {
let destinations: Destination[] = [];
try {
destinations = await api.getDestinations();
} catch {
destinations = [];
}
return (
<SiteShell showFooter>
<Suspense fallback={<div className="compare-page"><div className="container"><p className="plan-loading">加载对比台…</p></div></div>}>
<CompareClient destinations={destinations} />
</Suspense>
</SiteShell>
);
}

View File

@ -3312,19 +3312,50 @@ img { max-width: 100%; display: block; }
.matcher-result-card { .matcher-result-card {
display: grid; display: grid;
grid-template-columns: auto auto 1fr auto; grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto; gap: 10px;
gap: 4px 12px; padding: 12px 12px 12px 16px;
padding: 16px;
background: var(--bg-glass); background: var(--bg-glass);
border: var(--border-glass); border: var(--border-glass);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
text-decoration: none;
color: inherit; color: inherit;
transition: all 0.25s ease; transition: all 0.25s ease;
align-items: center; align-items: center;
} }
.matcher-result-main {
display: grid;
grid-template-columns: auto auto 1fr auto;
grid-template-rows: auto auto;
gap: 4px 12px;
align-items: center;
text-decoration: none;
color: inherit;
min-width: 0;
}
.matcher-add-btn {
flex-shrink: 0;
padding: 8px 12px;
border-radius: var(--radius-md);
border: var(--border-glass);
background: transparent;
color: var(--accent-2);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.matcher-add-btn:hover {
border-color: var(--accent-2);
background: color-mix(in srgb, var(--accent-2) 12%, transparent);
}
.matcher-option.selected {
border-color: rgba(78, 205, 196, 0.55);
box-shadow: 0 0 0 1px rgba(78, 205, 196, 0.25);
}
.matcher-result-card:hover { .matcher-result-card:hover {
border-color: rgba(78, 205, 196, 0.4); border-color: rgba(78, 205, 196, 0.4);
transform: translateY(-2px); transform: translateY(-2px);
@ -3793,9 +3824,11 @@ img { max-width: 100%; display: block; }
.matcher-fab { padding: 14px; border-radius: 50%; bottom: 90px; left: 16px; } .matcher-fab { padding: 14px; border-radius: 50%; bottom: 90px; left: 16px; }
.compare-metric-row { grid-template-columns: 1fr; } .compare-metric-row { grid-template-columns: 1fr; }
.tz-home-clock { flex-direction: column; text-align: center; } .tz-home-clock { flex-direction: column; text-align: center; }
.matcher-result-card { grid-template-columns: auto 1fr; } .matcher-result-card { grid-template-columns: 1fr; }
.matcher-result-main { grid-template-columns: auto 1fr; }
.matcher-result-score { grid-column: 2; grid-row: 2; } .matcher-result-score { grid-column: 2; grid-row: 2; }
.matcher-result-tags { grid-column: 1 / -1; } .matcher-result-tags { grid-column: 1 / -1; }
.matcher-add-btn { width: 100%; }
} }
/* ===== Visa Filters Enhancement ===== */ /* ===== Visa Filters Enhancement ===== */
@ -8997,3 +9030,68 @@ img { max-width: 100%; display: block; }
.plan-stats { grid-template-columns: 1fr 1fr; } .plan-stats { grid-template-columns: 1fr 1fr; }
.plan-page { padding-top: 88px; } .plan-page { padding-top: 88px; }
} }
/* ===== Compare page + modal actions ===== */
.compare-modal-actions,
.compare-page-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid color-mix(in srgb, var(--text-secondary) 18%, transparent);
}
.compare-page {
padding: 100px 0 80px;
min-height: 70vh;
}
.compare-page-hero {
margin-bottom: 24px;
}
.compare-page-hero h1 {
font-size: clamp(1.8rem, 4vw, 2.4rem);
margin: 8px 0;
}
.compare-page-hero p {
max-width: 560px;
color: var(--text-secondary);
line-height: 1.6;
}
.compare-pick-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
.compare-pick-chip {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 12px;
border-radius: var(--radius-lg);
border: var(--border-glass);
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: border-color 0.2s, background 0.2s;
}
.compare-pick-chip span { font-size: 1.4rem; }
.compare-pick-chip strong { font-size: 0.92rem; }
.compare-pick-chip small {
font-size: 0.75rem;
color: var(--text-secondary);
}
.compare-pick-chip:hover { border-color: var(--accent-2); }
.compare-pick-chip.on {
border-color: var(--accent-2);
background: color-mix(in srgb, var(--accent-2) 12%, transparent);
}
.compare-page-empty { margin-top: 12px; }
@media (max-width: 700px) {
.compare-modal-actions,
.compare-page-actions { justify-content: stretch; }
.compare-modal-actions .btn,
.compare-page-actions .btn { flex: 1; }
}

View File

@ -0,0 +1,228 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useToast } from "@/lib/toast";
import {
COMPARE_METRICS,
compareUrl,
getWinnerIdx,
overallScore,
parseCompareSlugs,
} from "@/lib/compareScore";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import type { Destination } from "@/lib/types";
interface Props {
destinations: Destination[];
}
export default function CompareClient({ destinations }: Props) {
const searchParams = useSearchParams();
const router = useRouter();
const { toast } = useToast();
const [picked, setPicked] = useState<string[]>([]);
useEffect(() => {
const fromUrl = parseCompareSlugs(searchParams.get("cities"));
if (fromUrl.length) {
setPicked(fromUrl.filter((s) => destinations.some((d) => d.slug === s)));
return;
}
setPicked([]);
}, [searchParams, destinations]);
const selected = useMemo(
() => picked.map((s) => destinations.find((d) => d.slug === s)).filter((d): d is Destination => !!d),
[picked, destinations]
);
const scores = selected.map(overallScore);
const winnerIdx = scores.length ? scores.indexOf(Math.max(...scores)) : -1;
const syncUrl = (slugs: string[]) => {
const href = compareUrl(slugs);
router.replace(href, { scroll: false });
};
const toggle = (slug: string) => {
setPicked((prev) => {
let next: string[];
if (prev.includes(slug)) next = prev.filter((s) => s !== slug);
else if (prev.length >= 4) {
toast("最多对比 4 座城市", "info");
return prev;
} else next = [...prev, slug];
syncUrl(next);
return next;
});
};
const addAll = () => {
if (selected.length < 1) return;
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
if (added === 0) {
toast(skipped ? "这些城市已在计划中" : "未能加入", "info");
router.push("/plan");
return;
}
toast(`已加入 ${added} 座城市${skipped ? `(跳过 ${skipped})` : ""}`);
router.push("/plan");
};
const addWinner = () => {
if (winnerIdx < 0) return;
const d = selected[winnerIdx];
const { added } = mergeDestinationsIntoTrip([d], 1);
toast(added ? `${d.emoji} ${d.name} 已写入计划` : "该城已在计划中", added ? "success" : "info");
if (added) router.push("/plan");
};
const share = async () => {
if (selected.length < 2) {
toast("至少选 2 座城市再分享", "info");
return;
}
const url = `${window.location.origin}${compareUrl(selected.map((d) => d.slug))}`;
await navigator.clipboard.writeText(url);
toast("对比链接已复制");
};
return (
<div className="compare-page">
<div className="container">
<nav className="detail-nav">
<Link href="/">← 返回首页</Link>
<Link href="/#destinations">目的地</Link>
<Link href="/plan">旅居计划</Link>
</nav>
<header className="compare-page-hero">
<span className="section-tag">⚖️ COMPARE</span>
<h1>城市对比台</h1>
<p>最多 4 城并排对比费用、网速、气候与评分。决定后一键写入旅居计划。</p>
</header>
<section className="compare-pick plan-panel">
<div className="plan-panel-head">
<h2>选择城市({picked.length}/4)</h2>
<div className="plan-actions">
<button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>清空</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>复制链接</button>
</div>
</div>
<div className="compare-pick-grid">
{destinations.map((d) => {
const on = picked.includes(d.slug);
return (
<button
key={d.slug}
type="button"
className={`compare-pick-chip${on ? " on" : ""}`}
onClick={() => toggle(d.slug)}
aria-pressed={on}
>
<span>{d.emoji}</span>
<strong>{d.name}</strong>
<small>¥{d.cost.toLocaleString()}</small>
</button>
);
})}
</div>
</section>
{selected.length < 2 ? (
<div className="plan-empty compare-page-empty">
<span className="plan-empty-icon">⚖️</span>
<p>再选至少 {Math.max(0, 2 - selected.length)} 座城市开始对比</p>
<Link href="/#destinations" className="btn btn-ghost">回首页勾选对比</Link>
</div>
) : (
<>
<section className="plan-panel">
<div className="compare-cards">
{selected.map((d, i) => (
<div key={d.slug} className={`compare-city-card${i === winnerIdx ? " winner" : ""}`}>
{i === winnerIdx && <span className="compare-winner-badge">👑 综合推荐</span>}
<span className="compare-city-emoji">{d.emoji}</span>
<h3>{d.name}</h3>
<span className="compare-city-country">{d.country}</span>
<div className="compare-score-ring">
<svg viewBox="0 0 80 80">
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="6" />
<circle
cx="40" cy="40" r="34" fill="none"
stroke="url(#comparePageGrad)" strokeWidth="6"
strokeLinecap="round"
strokeDasharray={`${scores[i] * 2.14} 214`}
transform="rotate(-90 40 40)"
/>
</svg>
<span className="compare-score-num">{scores[i]}</span>
</div>
<Link href={`/destinations/${d.slug}`} className="compare-city-link">查看详情 →</Link>
</div>
))}
</div>
<div className="compare-metrics">
{COMPARE_METRICS.map((m) => {
const values = selected.map(m.get);
const winIdx = getWinnerIdx(values, m.lowerBetter);
const maxVal = Math.max(...values);
return (
<div key={m.label} className="compare-metric-row">
<div className="compare-metric-label">{m.emoji} {m.label}</div>
<div className="compare-metric-bars">
{selected.map((d, i) => {
const val = m.get(d);
const pct = maxVal > 0 ? (val / maxVal) * 100 : 0;
return (
<div key={d.slug} className="compare-bar-item">
<div className="compare-bar-header">
<span>{d.emoji} {d.name}</span>
<span className={i === winIdx ? "compare-best" : ""}>
{i === winIdx && "🏆 "}{m.format(val)}
</span>
</div>
<div className="compare-bar-track">
<div
className={`compare-bar-fill${i === winIdx ? " best" : ""}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
<div className="compare-page-actions">
<button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}>
推荐城写入计划
</button>
<button type="button" className="btn btn-primary" onClick={addAll}>
全部写入旅居计划 →
</button>
</div>
</section>
<svg width="0" height="0">
<defs>
<linearGradient id="comparePageGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FF6B6B" />
<stop offset="50%" stopColor="#FFE66D" />
<stop offset="100%" stopColor="#4ECDC4" />
</linearGradient>
</defs>
</svg>
</>
)}
</div>
</div>
);
}

View File

@ -1,6 +1,10 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useToast } from "@/lib/toast";
import { COMPARE_METRICS, getWinnerIdx, overallScore, compareUrl } from "@/lib/compareScore";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
interface Props { interface Props {
@ -8,37 +12,30 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
interface Metric {
label: string;
emoji: string;
get: (d: Destination) => number;
format: (v: number) => string;
lowerBetter?: boolean;
}
const METRICS: Metric[] = [
{ label: "月生活费", emoji: "💰", get: (d) => d.cost, format: (v) => `¥${v.toLocaleString()}`, lowerBetter: true },
{ label: "网速", emoji: "📶", get: (d) => d.speed, format: (v) => `${v} Mbps` },
{ label: "温度", emoji: "🌡️", get: (d) => d.temperature, format: (v) => `${v}°C` },
{ label: "评分", emoji: "⭐", get: (d) => d.rating, format: (v) => v.toFixed(1) },
];
function getWinnerIdx(values: number[], lowerBetter?: boolean): number {
if (values.length === 0) return -1;
const best = lowerBetter ? Math.min(...values) : Math.max(...values);
return values.indexOf(best);
}
function overallScore(d: Destination): number {
const costScore = Math.max(0, 100 - d.cost / 150);
const speedScore = d.speed / 2;
const ratingScore = d.rating * 10;
return Math.round(costScore * 0.35 + speedScore * 0.25 + ratingScore * 0.4);
}
export default function CompareModal({ destinations, onClose }: Props) { export default function CompareModal({ destinations, onClose }: Props) {
const router = useRouter();
const { toast } = useToast();
const scores = destinations.map(overallScore); const scores = destinations.map(overallScore);
const winnerIdx = scores.indexOf(Math.max(...scores)); const winnerIdx = scores.indexOf(Math.max(...scores));
const pageHref = compareUrl(destinations.map((d) => d.slug));
const addAllToPlan = () => {
const { added, skipped } = mergeDestinationsIntoTrip(destinations, 1);
onClose();
if (added === 0) {
toast(skipped ? "这些城市已在计划中" : "未能加入", "info");
router.push("/plan");
return;
}
toast(`已加入 ${added} 座城市到计划${skipped ? `(跳过 ${skipped})` : ""}`);
router.push("/plan");
};
const shareCompare = async () => {
const url = `${window.location.origin}${pageHref}`;
await navigator.clipboard.writeText(url);
toast("对比链接已复制");
};
return ( return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}> <div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
@ -77,7 +74,7 @@ export default function CompareModal({ destinations, onClose }: Props) {
</div> </div>
<div className="compare-metrics"> <div className="compare-metrics">
{METRICS.map((m) => { {COMPARE_METRICS.map((m) => {
const values = destinations.map(m.get); const values = destinations.map(m.get);
const winIdx = getWinnerIdx(values, m.lowerBetter); const winIdx = getWinnerIdx(values, m.lowerBetter);
const maxVal = Math.max(...values); const maxVal = Math.max(...values);
@ -111,6 +108,12 @@ export default function CompareModal({ destinations, onClose }: Props) {
})} })}
</div> </div>
<div className="compare-modal-actions">
<button type="button" className="btn btn-ghost" onClick={shareCompare}>🔗 复制对比链接</button>
<Link href={pageHref} className="btn btn-ghost" onClick={onClose}>打开完整对比页</Link>
<button type="button" className="btn btn-primary" onClick={addAllToPlan}>全部写入计划 →</button>
</div>
<svg width="0" height="0"> <svg width="0" height="0">
<defs> <defs>
<linearGradient id="scoreGrad" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="scoreGrad" x1="0%" y1="0%" x2="100%" y2="0%">

View File

@ -1,7 +1,23 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useToast } from "@/lib/toast";
import { compareUrl } from "@/lib/compareScore";
import {
EMPTY_MATCHER_PREFS,
loadMatcherPrefs,
matcherPrefsComplete,
saveMatcherPrefs,
clearMatcherPrefs,
type MatcherPrefs,
type MatcherBudget,
type MatcherClimate,
type MatcherPriority,
type MatcherRegion,
} from "@/lib/matcherPrefs";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
interface Props { interface Props {
@ -10,18 +26,6 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
type Budget = "low" | "medium" | "high";
type Climate = "warm" | "mild" | "cool";
type Priority = "cost" | "speed" | "community" | "lifestyle";
type Region = "any" | "sea" | "europe" | "latam" | "asia";
interface Prefs {
budget: Budget | null;
climate: Climate | null;
priority: Priority | null;
region: Region | null;
}
const STEPS = [ const STEPS = [
{ title: "你的月预算?", subtitle: "包含住宿、餐饮、交通等日常开销", key: "budget" as const }, { title: "你的月预算?", subtitle: "包含住宿、餐饮、交通等日常开销", key: "budget" as const },
{ title: "偏好什么气候?", subtitle: "选择最让你舒适的环境", key: "climate" as const }, { title: "偏好什么气候?", subtitle: "选择最让你舒适的环境", key: "climate" as const },
@ -31,27 +35,27 @@ const STEPS = [
const OPTIONS = { const OPTIONS = {
budget: [ budget: [
{ value: "low" as Budget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" }, { value: "low" as MatcherBudget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" },
{ value: "medium" as Budget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" }, { value: "medium" as MatcherBudget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" },
{ value: "high" as Budget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" }, { value: "high" as MatcherBudget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" },
], ],
climate: [ climate: [
{ value: "warm" as Climate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" }, { value: "warm" as MatcherClimate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" },
{ value: "mild" as Climate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" }, { value: "mild" as MatcherClimate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" },
{ value: "cool" as Climate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" }, { value: "cool" as MatcherClimate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" },
], ],
priority: [ priority: [
{ value: "cost" as Priority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" }, { value: "cost" as MatcherPriority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" },
{ value: "speed" as Priority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" }, { value: "speed" as MatcherPriority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" },
{ value: "community" as Priority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" }, { value: "community" as MatcherPriority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" },
{ value: "lifestyle" as Priority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" }, { value: "lifestyle" as MatcherPriority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" },
], ],
region: [ region: [
{ value: "any" as Region, emoji: "🌏", label: "不限", desc: "全球探索" }, { value: "any" as MatcherRegion, emoji: "🌏", label: "不限", desc: "全球探索" },
{ value: "sea" as Region, emoji: "🌴", label: "东南亚", desc: "性价比之王" }, { value: "sea" as MatcherRegion, emoji: "🌴", label: "东南亚", desc: "性价比之王" },
{ value: "europe" as Region, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" }, { value: "europe" as MatcherRegion, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" },
{ value: "latam" as Region, emoji: "🌮", label: "拉美", desc: "活力与北美时区" }, { value: "latam" as MatcherRegion, emoji: "🌮", label: "拉美", desc: "活力与北美时区" },
{ value: "asia" as Region, emoji: "🏯", label: "东亚", desc: "安全高效现代" }, { value: "asia" as MatcherRegion, emoji: "🏯", label: "东亚", desc: "安全高效现代" },
], ],
}; };
@ -59,7 +63,10 @@ function parseNomads(n: string): number {
return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0; return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0;
} }
function scoreDestination(d: Destination, prefs: Required<Pick<Prefs, "budget" | "climate" | "priority" | "region">>): number { function scoreDestination(
d: Destination,
prefs: Required<Pick<MatcherPrefs, "budget" | "climate" | "priority" | "region">>
): number {
let score = 0; let score = 0;
if (prefs.budget === "low") score += d.cost <= 5000 ? 35 : Math.max(0, 35 - (d.cost - 5000) / 200); if (prefs.budget === "low") score += d.cost <= 5000 ? 35 : Math.max(0, 35 - (d.cost - 5000) / 200);
@ -83,17 +90,34 @@ function scoreDestination(d: Destination, prefs: Required<Pick<Prefs, "budget" |
} }
export default function DestinationMatcher({ destinations, open, onClose }: Props) { export default function DestinationMatcher({ destinations, open, onClose }: Props) {
const router = useRouter();
const { toast } = useToast();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [prefs, setPrefs] = useState<Prefs>({ budget: null, climate: null, priority: null, region: null }); const [prefs, setPrefs] = useState<MatcherPrefs>(EMPTY_MATCHER_PREFS);
const [direction, setDirection] = useState<"forward" | "back">("forward"); const [direction, setDirection] = useState<"forward" | "back">("forward");
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (!open) return;
const saved = loadMatcherPrefs();
setPrefs(saved);
if (matcherPrefsComplete(saved)) setStep(STEPS.length);
else {
const filled = STEPS.findIndex((s) => !saved[s.key]);
setStep(filled === -1 ? 0 : filled);
}
setHydrated(true);
}, [open]);
const results = useMemo(() => { const results = useMemo(() => {
if (!prefs.budget || !prefs.climate || !prefs.priority || !prefs.region) return []; if (!matcherPrefsComplete(prefs)) return [];
return [...destinations] return [...destinations]
.map((d) => ({ dest: d, score: scoreDestination(d, prefs as Required<typeof prefs>) })) .map((d) => ({ dest: d, score: scoreDestination(d, prefs) }))
.sort((a, b) => b.score - a.score); .sort((a, b) => b.score - a.score);
}, [destinations, prefs]); }, [destinations, prefs]);
const top3 = results.slice(0, 3).map((r) => r.dest);
const currentKey = STEPS[step]?.key; const currentKey = STEPS[step]?.key;
const isComplete = step >= STEPS.length; const isComplete = step >= STEPS.length;
const progress = isComplete ? 100 : ((step + 1) / STEPS.length) * 100; const progress = isComplete ? 100 : ((step + 1) / STEPS.length) * 100;
@ -101,23 +125,49 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
const select = (value: string) => { const select = (value: string) => {
if (!currentKey) return; if (!currentKey) return;
setDirection("forward"); setDirection("forward");
setPrefs((p) => ({ ...p, [currentKey]: value })); const next = { ...prefs, [currentKey]: value };
setPrefs(next);
saveMatcherPrefs(next);
setTimeout(() => setStep((s) => s + 1), 280); setTimeout(() => setStep((s) => s + 1), 280);
}; };
const goBack = () => { const goBack = () => {
if (step === 0) { onClose(); return; } if (step === 0) {
onClose();
return;
}
setDirection("back"); setDirection("back");
setStep((s) => s - 1); setStep((s) => s - 1);
}; };
const reset = () => { const reset = () => {
setStep(0); setStep(0);
setPrefs({ budget: null, climate: null, priority: null, region: null }); setPrefs(EMPTY_MATCHER_PREFS);
clearMatcherPrefs();
setDirection("forward"); setDirection("forward");
}; };
if (!open) return null; const buildPlanFromTop = () => {
if (top3.length === 0) return;
const { added, skipped } = mergeDestinationsIntoTrip(top3, 1);
onClose();
if (added === 0) {
toast(skipped ? "这几座城已在计划中" : "未能加入计划", "info");
router.push("/plan");
return;
}
toast(`已将 Top ${added} 城写入旅居计划${skipped ? `(跳过 ${skipped} 座已有)` : ""}`);
router.push("/plan");
};
const addOne = (dest: Destination, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const { added } = mergeDestinationsIntoTrip([dest], 1);
toast(added ? `${dest.emoji} ${dest.name} 已加入计划` : "该城已在计划中", added ? "success" : "info");
};
if (!open || !hydrated) return null;
return ( return (
<div className="modal-overlay open matcher-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}> <div className="modal-overlay open matcher-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
@ -140,7 +190,7 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
{OPTIONS[currentKey].map((opt) => ( {OPTIONS[currentKey].map((opt) => (
<button <button
key={opt.value} key={opt.value}
className="matcher-option" className={`matcher-option${prefs[currentKey] === opt.value ? " selected" : ""}`}
onClick={() => select(opt.value)} onClick={() => select(opt.value)}
> >
<span className="matcher-option-emoji">{opt.emoji}</span> <span className="matcher-option-emoji">{opt.emoji}</span>
@ -163,16 +213,12 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
<div className="matcher-step-meta"> <div className="matcher-step-meta">
<span className="section-tag">✨ YOUR MATCHES</span> <span className="section-tag">✨ YOUR MATCHES</span>
<h2>为你推荐的目的地</h2> <h2>为你推荐的目的地</h2>
<p>根据你的偏好智能匹配,匹配度越高越适合你</p> <p>偏好已保存。可一键写入旅居计划,或先对比再决定。</p>
</div> </div>
<div className="matcher-result-list"> <div className="matcher-result-list">
{results.map(({ dest, score }, i) => ( {results.map(({ dest, score }, i) => (
<Link <div key={dest.slug} className={`matcher-result-card${i === 0 ? " top" : ""}`}>
key={dest.slug} <Link href={`/destinations/${dest.slug}`} className="matcher-result-main" onClick={onClose}>
href={`/destinations/${dest.slug}`}
className={`matcher-result-card${i === 0 ? " top" : ""}`}
onClick={onClose}
>
<div className="matcher-result-rank"> <div className="matcher-result-rank">
{i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`} {i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`}
</div> </div>
@ -193,11 +239,26 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
<span>⭐ {dest.rating}</span> <span>⭐ {dest.rating}</span>
</div> </div>
</Link> </Link>
<button type="button" className="matcher-add-btn" onClick={(e) => addOne(dest, e)}>
+ 计划
</button>
</div>
))} ))}
</div> </div>
<div className="matcher-actions"> <div className="matcher-actions">
<button className="btn btn-ghost" onClick={reset}>🔄 重新测试</button> <button className="btn btn-ghost" onClick={reset}>🔄 重新测试</button>
<button className="btn btn-primary" onClick={onClose}>开始探索 🚀</button> {top3.length >= 2 && (
<Link
href={compareUrl(top3.map((d) => d.slug))}
className="btn btn-ghost"
onClick={onClose}
>
⚖️ 对比 Top {top3.length}
</Link>
)}
<button className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
把 Top {Math.min(3, top3.length)} 写入计划 →
</button>
</div> </div>
</div> </div>
)} )}

View File

@ -17,6 +17,7 @@ const TYPE_LABELS: Record<string, string> = {
const PAGE_RESULTS: SearchResult[] = [ const PAGE_RESULTS: SearchResult[] = [
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" }, { type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" }, { type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" }, { type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" }, { type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState, type ComponentType } from "react"; import { useEffect, useState, type ComponentType } from "react";
import Link from "next/link";
import dynamic from "next/dynamic"; 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";
@ -253,7 +254,15 @@ export default function HomeClient(props: Props) {
{compareList.length > 0 && ( {compareList.length > 0 && (
<div className="compare-bar"> <div className="compare-bar">
<span>⚖️ 已选 {compareList.length}/4</span> <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" : ""}`}
aria-disabled={compareList.length < 2}
onClick={(e) => { if (compareList.length < 2) e.preventDefault(); }}
>
完整对比页
</Link>
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button> <button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
</div> </div>
)} )}

View File

@ -351,6 +351,14 @@ export default function MovePlanClient({ destinations }: Props) {
<section className="plan-panel"> <section className="plan-panel">
<div className="plan-panel-head"> <div className="plan-panel-head">
<h2>行程内城市对比</h2> <h2>行程内城市对比</h2>
{trip.length >= 2 && (
<Link
href={`/compare?cities=${trip.map((t) => t.slug).slice(0, 4).join(",")}`}
className="btn btn-ghost btn-sm"
>
打开对比台 →
</Link>
)}
</div> </div>
<div className="plan-compare-wrap"> <div className="plan-compare-wrap">
<table className="plan-compare"> <table className="plan-compare">

View File

@ -0,0 +1,39 @@
import type { Destination } from "@/lib/types";
export interface CompareMetric {
label: string;
emoji: string;
get: (d: Destination) => number;
format: (v: number) => string;
lowerBetter?: boolean;
}
export const COMPARE_METRICS: CompareMetric[] = [
{ label: "月生活费", emoji: "💰", get: (d) => d.cost, format: (v) => `¥${v.toLocaleString()}`, lowerBetter: true },
{ label: "网速", emoji: "📶", get: (d) => d.speed, format: (v) => `${v} Mbps` },
{ label: "温度", emoji: "🌡️", get: (d) => d.temperature, format: (v) => `${v}°C` },
{ label: "评分", emoji: "⭐", get: (d) => d.rating, format: (v) => v.toFixed(1) },
];
export function getWinnerIdx(values: number[], lowerBetter?: boolean): number {
if (values.length === 0) return -1;
const best = lowerBetter ? Math.min(...values) : Math.max(...values);
return values.indexOf(best);
}
export function overallScore(d: Destination): number {
const costScore = Math.max(0, 100 - d.cost / 150);
const speedScore = d.speed / 2;
const ratingScore = d.rating * 10;
return Math.round(costScore * 0.35 + speedScore * 0.25 + ratingScore * 0.4);
}
export function compareUrl(slugs: string[]): string {
const unique = [...new Set(slugs.filter(Boolean))].slice(0, 4);
return unique.length ? `/compare?cities=${unique.join(",")}` : "/compare";
}
export function parseCompareSlugs(raw: string | null | undefined): string[] {
if (!raw) return [];
return [...new Set(raw.split(",").map((s) => s.trim()).filter(Boolean))].slice(0, 4);
}

View File

@ -0,0 +1,58 @@
const KEY = "nomadro-matcher-prefs";
export type MatcherBudget = "low" | "medium" | "high";
export type MatcherClimate = "warm" | "mild" | "cool";
export type MatcherPriority = "cost" | "speed" | "community" | "lifestyle";
export type MatcherRegion = "any" | "sea" | "europe" | "latam" | "asia";
export interface MatcherPrefs {
budget: MatcherBudget | null;
climate: MatcherClimate | null;
priority: MatcherPriority | null;
region: MatcherRegion | null;
}
export const EMPTY_MATCHER_PREFS: MatcherPrefs = {
budget: null,
climate: null,
priority: null,
region: null,
};
export function loadMatcherPrefs(): MatcherPrefs {
if (typeof window === "undefined") return { ...EMPTY_MATCHER_PREFS };
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...EMPTY_MATCHER_PREFS };
const data = JSON.parse(raw) as Partial<MatcherPrefs>;
return {
budget: data.budget ?? null,
climate: data.climate ?? null,
priority: data.priority ?? null,
region: data.region ?? null,
};
} catch {
return { ...EMPTY_MATCHER_PREFS };
}
}
export function saveMatcherPrefs(prefs: MatcherPrefs) {
if (typeof window === "undefined") return;
localStorage.setItem(KEY, JSON.stringify(prefs));
}
export function clearMatcherPrefs() {
if (typeof window === "undefined") return;
localStorage.removeItem(KEY);
}
export function matcherPrefsComplete(
p: MatcherPrefs
): p is {
budget: MatcherBudget;
climate: MatcherClimate;
priority: MatcherPriority;
region: MatcherRegion;
} {
return !!(p.budget && p.climate && p.priority && p.region);
}

View File

@ -0,0 +1,36 @@
import type { Destination, TripItem } from "@/lib/types";
import { loadTrip, saveTrip } from "@/lib/tripStorage";
export function destToTripItem(d: Destination, months = 1): TripItem {
return {
slug: d.slug,
name: d.name,
country: d.country,
emoji: d.emoji,
cost: d.cost,
months,
note: "",
};
}
/** Merge destinations into local trip. Skips cities already present. */
export function mergeDestinationsIntoTrip(
dests: Destination[],
months = 1
): { trip: TripItem[]; added: number; skipped: number } {
const trip = loadTrip<TripItem>();
const existing = new Set(trip.map((t) => t.slug));
let added = 0;
let skipped = 0;
for (const d of dests) {
if (existing.has(d.slug)) {
skipped += 1;
continue;
}
trip.push(destToTripItem(d, months));
existing.add(d.slug);
added += 1;
}
if (added > 0) saveTrip(trip);
return { trip, added, skipped };
}

View File

@ -0,0 +1,133 @@
"""Direct SFTP sync + native frontend rebuild (when Gitea push fails)."""
from __future__ import annotations
import sys
import time
from pathlib import Path
import paramiko
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
HOST = "107.173.30.245"
USER = "root"
PASS = "Xiao4669805"
REMOTE = "/opt/nomadweb"
DOMAIN = "nomadweb.nomadro.com"
FILES = [
"README.md",
"frontend/src/app/changelog/page.tsx",
"frontend/src/app/globals.css",
"frontend/src/app/profile/page.tsx",
"frontend/src/app/plan/page.tsx",
"frontend/src/app/compare/page.tsx",
"frontend/src/components/DestinationDetailClient.tsx",
"frontend/src/components/DestinationMatcher.tsx",
"frontend/src/components/CompareModal.tsx",
"frontend/src/components/CompareClient.tsx",
"frontend/src/components/GlobalSearch.tsx",
"frontend/src/components/HomeClient.tsx",
"frontend/src/components/KeyboardShortcuts.tsx",
"frontend/src/components/Navbar.tsx",
"frontend/src/components/TripPlanner.tsx",
"frontend/src/components/WorldMap.tsx",
"frontend/src/components/MovePlanClient.tsx",
"frontend/src/lib/types.ts",
"frontend/src/lib/planMeta.ts",
"frontend/src/lib/compareScore.ts",
"frontend/src/lib/tripActions.ts",
"frontend/src/lib/matcherPrefs.ts",
]
BUILD = f"""
set -euo pipefail
exec 9>/var/lock/nomadro-deploy.lock
if ! flock -n 9; then
echo LOCK_BUSY
exit 75
fi
REMOTE={REMOTE}
DOMAIN={DOMAIN}
export NEXT_TELEMETRY_DISABLED=1
export NEXT_PUBLIC_API_URL=https://${{DOMAIN}}/api/v1
export NEXT_PUBLIC_SITE_URL=https://${{DOMAIN}}
export NODE_ENV=production
cd "$REMOTE/frontend"
echo BUILD_START
npm run build
mkdir -p .next/standalone/.next
rm -rf .next/standalone/public .next/standalone/.next/static
cp -a public .next/standalone/public
cp -a .next/static .next/standalone/.next/static
systemctl restart nomadro-web
ok=0
for i in $(seq 1 30); do
web=$(curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1:3055/ || true)
api=$(curl -s http://127.0.0.1:8055/api/v1/health || true)
echo "try=$i web=$web api=$api"
if echo "$api" | grep -q '"status":"ok"' && [ "$web" = "200" ]; then
ok=1
break
fi
sleep 1
done
curl -skI "https://${{DOMAIN}}/plan" | head -8
curl -sk "https://${{DOMAIN}}/api/v1/health"
echo DIRECT_SYNC=1
[ "$ok" = 1 ]
"""
def ensure_dir(sftp: paramiko.SFTPClient, path: str) -> None:
parts = path.strip("/").split("/")
cur = ""
for p in parts:
cur += "/" + p
try:
sftp.stat(cur)
except FileNotFoundError:
try:
sftp.mkdir(cur)
except OSError:
pass
def main() -> int:
root = Path(__file__).resolve().parents[1]
t0 = time.time()
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
print("Connecting...")
client.connect(HOST, username=USER, password=PASS, timeout=30)
sftp = client.open_sftp()
for rel in FILES:
local = root / rel
remote = f"{REMOTE}/{rel}"
print(f"UPLOAD {rel}")
ensure_dir(sftp, str(Path(remote).parent).replace("\\", "/"))
sftp.put(str(local), remote)
sftp.close()
print("Uploaded. Building frontend...")
_, stdout, stderr = client.exec_command(
f"cat > /tmp/nomadro-direct.sh <<'EOF'\n{BUILD}\nEOF\nbash /tmp/nomadro-direct.sh",
timeout=900,
)
while True:
line = stdout.readline()
if not line:
break
print(line, end="")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
if err.strip():
print("LOG:", err[-4000:])
print("exit:", code)
client.close()
print(f"Done in {int(time.time() - t0)}s")
return code
if __name__ == "__main__":
raise SystemExit(main())