diff --git a/README.md b/README.md index a69b46f..cf8b8f5 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ docker compose up -d | ⏱️ 专注时钟 | 番茄钟深度工作计时 | | 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 | | 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 | +| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 | | 📜 更新日志 | `/changelog` 迭代记录 | | 🏧 取现避坑 | ATM / DCC / 换汇建议 | | 🥗 饮食饮水 | 各城饮水与街头饮食注意 | @@ -196,6 +197,7 @@ docker compose up -d | `/profile` | 用户中心 | | `/destinations/[slug]` | 目的地详情(城市画像、加入计划) | | `/plan` | 旅居计划中心(时间轴 / 预算 / 清单 / 分享) | +| `/compare` | 城市对比台(可分享 URL → 写入计划) | | `/tools` | 工具箱聚合 | | `/blog/[slug]` | 博客详情(阅读进度) | diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index dd3c927..42c39a6 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,15 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-08-29", + tag: "决策闭环", + items: [ + "智能匹配:偏好本地保存;Top 3 一键写入旅居计划;单城「+ 计划」;对比 Top 结果", + "全新「城市对比台」/compare:可分享 URL、并排指标、推荐城/全部写入计划", + "首页对比条与计划中心可跳转完整对比页", + ], + }, { date: "2026-08-29", tag: "核心功能", diff --git a/frontend/src/app/compare/page.tsx b/frontend/src/app/compare/page.tsx new file mode 100644 index 0000000..81529c5 --- /dev/null +++ b/frontend/src/app/compare/page.tsx @@ -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 ( + + 加载对比台…}> + + + + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d723897..a32deff 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3312,19 +3312,50 @@ img { max-width: 100%; display: block; } .matcher-result-card { display: grid; - grid-template-columns: auto auto 1fr auto; - grid-template-rows: auto auto; - gap: 4px 12px; - padding: 16px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 12px 12px 12px 16px; background: var(--bg-glass); border: var(--border-glass); border-radius: var(--radius-lg); - text-decoration: none; color: inherit; transition: all 0.25s ease; 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 { border-color: rgba(78, 205, 196, 0.4); transform: translateY(-2px); @@ -3793,9 +3824,11 @@ img { max-width: 100%; display: block; } .matcher-fab { padding: 14px; border-radius: 50%; bottom: 90px; left: 16px; } .compare-metric-row { grid-template-columns: 1fr; } .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-tags { grid-column: 1 / -1; } + .matcher-add-btn { width: 100%; } } /* ===== Visa Filters Enhancement ===== */ @@ -8997,3 +9030,68 @@ img { max-width: 100%; display: block; } .plan-stats { grid-template-columns: 1fr 1fr; } .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; } +} diff --git a/frontend/src/components/CompareClient.tsx b/frontend/src/components/CompareClient.tsx new file mode 100644 index 0000000..c4e9d2a --- /dev/null +++ b/frontend/src/components/CompareClient.tsx @@ -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([]); + + 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 ( + + + + ← 返回首页 + 目的地 + 旅居计划 + + + + ⚖️ COMPARE + 城市对比台 + 最多 4 城并排对比费用、网速、气候与评分。决定后一键写入旅居计划。 + + + + + 选择城市({picked.length}/4) + + { setPicked([]); syncUrl([]); }}>清空 + 复制链接 + + + + {destinations.map((d) => { + const on = picked.includes(d.slug); + return ( + toggle(d.slug)} + aria-pressed={on} + > + {d.emoji} + {d.name} + ¥{d.cost.toLocaleString()} + + ); + })} + + + + {selected.length < 2 ? ( + + ⚖️ + 再选至少 {Math.max(0, 2 - selected.length)} 座城市开始对比 + 回首页勾选对比 + + ) : ( + <> + + + {selected.map((d, i) => ( + + {i === winnerIdx && 👑 综合推荐} + {d.emoji} + {d.name} + {d.country} + + + + + + {scores[i]} + + 查看详情 → + + ))} + + + + {COMPARE_METRICS.map((m) => { + const values = selected.map(m.get); + const winIdx = getWinnerIdx(values, m.lowerBetter); + const maxVal = Math.max(...values); + return ( + + {m.emoji} {m.label} + + {selected.map((d, i) => { + const val = m.get(d); + const pct = maxVal > 0 ? (val / maxVal) * 100 : 0; + return ( + + + {d.emoji} {d.name} + + {i === winIdx && "🏆 "}{m.format(val)} + + + + + + + ); + })} + + + ); + })} + + + + + 推荐城写入计划 + + + 全部写入旅居计划 → + + + + + + + + + + + + + + > + )} + + + ); +} diff --git a/frontend/src/components/CompareModal.tsx b/frontend/src/components/CompareModal.tsx index 1337364..a641dba 100644 --- a/frontend/src/components/CompareModal.tsx +++ b/frontend/src/components/CompareModal.tsx @@ -1,6 +1,10 @@ "use client"; 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"; interface Props { @@ -8,37 +12,30 @@ interface Props { 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) { + const router = useRouter(); + const { toast } = useToast(); const scores = destinations.map(overallScore); 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 ( e.target === e.currentTarget && onClose()}> @@ -77,7 +74,7 @@ export default function CompareModal({ destinations, onClose }: Props) { - {METRICS.map((m) => { + {COMPARE_METRICS.map((m) => { const values = destinations.map(m.get); const winIdx = getWinnerIdx(values, m.lowerBetter); const maxVal = Math.max(...values); @@ -111,6 +108,12 @@ export default function CompareModal({ destinations, onClose }: Props) { })} + + 🔗 复制对比链接 + 打开完整对比页 + 全部写入计划 → + + diff --git a/frontend/src/components/DestinationMatcher.tsx b/frontend/src/components/DestinationMatcher.tsx index 290c731..2138ded 100644 --- a/frontend/src/components/DestinationMatcher.tsx +++ b/frontend/src/components/DestinationMatcher.tsx @@ -1,7 +1,23 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; 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"; interface Props { @@ -10,18 +26,6 @@ interface Props { 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 = [ { title: "你的月预算?", subtitle: "包含住宿、餐饮、交通等日常开销", key: "budget" as const }, { title: "偏好什么气候?", subtitle: "选择最让你舒适的环境", key: "climate" as const }, @@ -31,27 +35,27 @@ const STEPS = [ const OPTIONS = { budget: [ - { value: "low" as Budget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" }, - { value: "medium" as Budget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" }, - { value: "high" as Budget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" }, + { value: "low" as MatcherBudget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" }, + { value: "medium" as MatcherBudget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" }, + { value: "high" as MatcherBudget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" }, ], climate: [ - { value: "warm" as Climate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" }, - { value: "mild" as Climate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" }, - { value: "cool" as Climate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" }, + { value: "warm" as MatcherClimate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" }, + { value: "mild" as MatcherClimate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" }, + { value: "cool" as MatcherClimate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" }, ], priority: [ - { value: "cost" as Priority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" }, - { value: "speed" as Priority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" }, - { value: "community" as Priority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" }, - { value: "lifestyle" as Priority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" }, + { value: "cost" as MatcherPriority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" }, + { value: "speed" as MatcherPriority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" }, + { value: "community" as MatcherPriority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" }, + { value: "lifestyle" as MatcherPriority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" }, ], region: [ - { value: "any" as Region, emoji: "🌏", label: "不限", desc: "全球探索" }, - { value: "sea" as Region, emoji: "🌴", label: "东南亚", desc: "性价比之王" }, - { value: "europe" as Region, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" }, - { value: "latam" as Region, emoji: "🌮", label: "拉美", desc: "活力与北美时区" }, - { value: "asia" as Region, emoji: "🏯", label: "东亚", desc: "安全高效现代" }, + { value: "any" as MatcherRegion, emoji: "🌏", label: "不限", desc: "全球探索" }, + { value: "sea" as MatcherRegion, emoji: "🌴", label: "东南亚", desc: "性价比之王" }, + { value: "europe" as MatcherRegion, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" }, + { value: "latam" as MatcherRegion, 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; } -function scoreDestination(d: Destination, prefs: Required>): number { +function scoreDestination( + d: Destination, + prefs: Required> +): number { let score = 0; 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({ budget: null, climate: null, priority: null, region: null }); + const [prefs, setPrefs] = useState(EMPTY_MATCHER_PREFS); 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(() => { - if (!prefs.budget || !prefs.climate || !prefs.priority || !prefs.region) return []; + if (!matcherPrefsComplete(prefs)) return []; return [...destinations] - .map((d) => ({ dest: d, score: scoreDestination(d, prefs as Required) })) + .map((d) => ({ dest: d, score: scoreDestination(d, prefs) })) .sort((a, b) => b.score - a.score); }, [destinations, prefs]); + const top3 = results.slice(0, 3).map((r) => r.dest); + const currentKey = STEPS[step]?.key; const isComplete = step >= STEPS.length; 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) => { if (!currentKey) return; setDirection("forward"); - setPrefs((p) => ({ ...p, [currentKey]: value })); + const next = { ...prefs, [currentKey]: value }; + setPrefs(next); + saveMatcherPrefs(next); setTimeout(() => setStep((s) => s + 1), 280); }; const goBack = () => { - if (step === 0) { onClose(); return; } + if (step === 0) { + onClose(); + return; + } setDirection("back"); setStep((s) => s - 1); }; const reset = () => { setStep(0); - setPrefs({ budget: null, climate: null, priority: null, region: null }); + setPrefs(EMPTY_MATCHER_PREFS); + clearMatcherPrefs(); 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 ( e.target === e.currentTarget && onClose()}> @@ -140,7 +190,7 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop {OPTIONS[currentKey].map((opt) => ( select(opt.value)} > {opt.emoji} @@ -163,41 +213,52 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop ✨ YOUR MATCHES 为你推荐的目的地 - 根据你的偏好智能匹配,匹配度越高越适合你 + 偏好已保存。可一键写入旅居计划,或先对比再决定。 {results.map(({ dest, score }, i) => ( - - - {i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`} - - {dest.emoji} - - {dest.name} - {dest.country} · {dest.tag} - - - - + + + + {i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`} - {Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100)}% 匹配 - - - 💰 ¥{dest.cost.toLocaleString()} - 📶 {dest.speed}Mbps - ⭐ {dest.rating} - - + {dest.emoji} + + {dest.name} + {dest.country} · {dest.tag} + + + + + + {Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100)}% 匹配 + + + 💰 ¥{dest.cost.toLocaleString()} + 📶 {dest.speed}Mbps + ⭐ {dest.rating} + + + addOne(dest, e)}> + + 计划 + + ))} 🔄 重新测试 - 开始探索 🚀 + {top3.length >= 2 && ( + d.slug))} + className="btn btn-ghost" + onClick={onClose} + > + ⚖️ 对比 Top {top3.length} + + )} + + 把 Top {Math.min(3, top3.length)} 写入计划 → + )} diff --git a/frontend/src/components/GlobalSearch.tsx b/frontend/src/components/GlobalSearch.tsx index addd178..f70e5ce 100644 --- a/frontend/src/components/GlobalSearch.tsx +++ b/frontend/src/components/GlobalSearch.tsx @@ -17,6 +17,7 @@ const TYPE_LABELS: Record = { const PAGE_RESULTS: SearchResult[] = [ { type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" }, + { type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" }, { type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" }, { type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" }, { type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" }, diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 2cfde0d..f9b1408 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -1,6 +1,7 @@ "use client"; 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"; @@ -253,8 +254,16 @@ export default function HomeClient(props: Props) { {compareList.length > 0 && ( ⚖️ 已选 {compareList.length}/4 - 对比城市 - { setCompareList([]); setCompareResults(null); }}>清空 + 快速对比 + = 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(); }} + > + 完整对比页 + + { setCompareList([]); setCompareResults(null); }}>清空 )} diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx index 50c3d80..98bfa61 100644 --- a/frontend/src/components/MovePlanClient.tsx +++ b/frontend/src/components/MovePlanClient.tsx @@ -351,6 +351,14 @@ export default function MovePlanClient({ destinations }: Props) { 行程内城市对比 + {trip.length >= 2 && ( + t.slug).slice(0, 4).join(",")}`} + className="btn btn-ghost btn-sm" + > + 打开对比台 → + + )} diff --git a/frontend/src/lib/compareScore.ts b/frontend/src/lib/compareScore.ts new file mode 100644 index 0000000..b923fc9 --- /dev/null +++ b/frontend/src/lib/compareScore.ts @@ -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); +} diff --git a/frontend/src/lib/matcherPrefs.ts b/frontend/src/lib/matcherPrefs.ts new file mode 100644 index 0000000..ff8a889 --- /dev/null +++ b/frontend/src/lib/matcherPrefs.ts @@ -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; + 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); +} diff --git a/frontend/src/lib/tripActions.ts b/frontend/src/lib/tripActions.ts new file mode 100644 index 0000000..00c83e0 --- /dev/null +++ b/frontend/src/lib/tripActions.ts @@ -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(); + 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 }; +} diff --git a/scripts/deploy_direct_sync.py b/scripts/deploy_direct_sync.py new file mode 100644 index 0000000..3319e72 --- /dev/null +++ b/scripts/deploy_direct_sync.py @@ -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())
加载对比台…
最多 4 城并排对比费用、网速、气候与评分。决定后一键写入旅居计划。
再选至少 {Math.max(0, 2 - selected.length)} 座城市开始对比
根据你的偏好智能匹配,匹配度越高越适合你
偏好已保存。可一键写入旅居计划,或先对比再决定。