diff --git a/README.md b/README.md
index 16a1602..7ef79e7 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,9 @@ docker compose up -d
| 💻 联合办公 | 精选 Co-working Space,城市/评分/网速排序 |
| 🌤️ 最佳月份 | 12 月气候适宜度热力表,避开雨季 |
| 🧳 行李清单 | 可勾选打包清单,localStorage 持久化 |
+| 🎲 命运转盘 | 随机目的地抽选,趣味决策 |
+| ✈️ 机票估算 | 出发城 × 季节 × 人数粗算往返票价 |
+| 📅 活动日历 | Meetup / 工作坊 / 线上活动 |
| 📝 博客 | 标签筛选 + 阅读进度 + 相关推荐 + 分享 |
| ❓ FAQ | 搜索 + 展开/收起全部 |
@@ -119,8 +122,9 @@ docker compose up -d
- 🔔 全局 Toast 通知
- 💡 游民小贴士浮动提示
- 🍪 Cookie 同意横幅
-- ⌨️ 快捷键:`M` 智能匹配、`?` 帮助面板
+- ⌨️ 快捷键:`M` 智能匹配、`G` 命运转盘、`?` 帮助面板
- 📱 响应式布局,移动端适配
+- 🔒 隐私政策页 `/privacy`
### 用户系统
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 9e714c8..4bb8e0a 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -5795,3 +5795,310 @@ img { max-width: 100%; display: block; }
.nomad-tips-dots span.active { background: var(--accent-3); }
+
+/* ===== Spin Globe ===== */
+.spin-card {
+ max-width: 520px;
+ margin: 0 auto;
+ padding: 40px 28px;
+ text-align: center;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ backdrop-filter: blur(20px);
+}
+
+.spin-orb {
+ position: relative;
+ width: 140px;
+ height: 140px;
+ margin: 0 auto 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.spin-emoji {
+ font-size: 4rem;
+ position: relative;
+ z-index: 2;
+ transition: transform 0.1s;
+}
+
+.spin-orb.spinning .spin-emoji {
+ animation: spinBounce 0.18s ease infinite;
+}
+
+@keyframes spinBounce {
+ 0%, 100% { transform: scale(1) rotate(-4deg); }
+ 50% { transform: scale(1.12) rotate(4deg); }
+}
+
+.spin-orb-ring {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ border: 2px dashed rgba(78, 205, 196, 0.35);
+ animation: compassSpin 12s linear infinite;
+}
+
+.spin-orb-ring-2 {
+ inset: 12px;
+ border-style: solid;
+ border-color: rgba(255, 230, 109, 0.25);
+ animation-direction: reverse;
+ animation-duration: 8s;
+}
+
+.spin-orb.spinning .spin-orb-ring {
+ animation-duration: 1.2s;
+ border-color: var(--accent-3);
+}
+
+.spin-city-name {
+ font-size: 1.5rem;
+ margin-bottom: 8px;
+}
+
+.spin-hint {
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ margin-bottom: 20px;
+}
+
+.spin-btn { min-width: 200px; }
+
+.spin-result {
+ margin-top: 8px;
+ animation: toastIn 0.4s ease;
+}
+
+.spin-result-tag {
+ color: var(--accent-2);
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+.spin-result-meta {
+ display: flex;
+ justify-content: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: 12px;
+}
+
+.spin-result-desc {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ line-height: 1.6;
+ margin-bottom: 18px;
+}
+
+.spin-result-actions {
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+/* ===== Flight Cost ===== */
+.flight-card {
+ display: grid;
+ grid-template-columns: 1.1fr 0.9fr;
+ gap: 28px;
+ padding: 28px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ backdrop-filter: blur(20px);
+}
+
+.flight-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.flight-arrow {
+ text-align: center;
+ font-size: 1.4rem;
+ color: var(--accent-3);
+ opacity: 0.7;
+}
+
+.flight-result {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(78, 205, 196, 0.06);
+ border: 1px solid rgba(78, 205, 196, 0.15);
+ border-radius: var(--radius-lg);
+}
+
+.flight-route {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ font-weight: 600;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+.flight-plane {
+ animation: planeOrbit 3s ease-in-out infinite;
+}
+
+.flight-nums {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+}
+
+.flight-nums div {
+ text-align: center;
+}
+
+.flight-nums small {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ margin-bottom: 4px;
+}
+
+.flight-nums strong {
+ font-size: 1.25rem;
+}
+
+.flight-total {
+ grid-column: 1 / -1;
+ padding-top: 10px;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.flight-total strong {
+ font-size: 1.75rem;
+}
+
+.flight-note {
+ margin-top: 16px;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ text-align: center;
+}
+
+/* ===== Nomad Events ===== */
+.events-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: center;
+ margin-bottom: 28px;
+}
+
+.events-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 20px;
+}
+
+.event-card {
+ padding: 22px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-lg);
+ backdrop-filter: blur(16px);
+ transition: transform 0.3s, box-shadow 0.3s;
+}
+
+.event-card:hover {
+ transform: translateY(-4px);
+ box-shadow: var(--shadow-card);
+}
+
+.event-card-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.event-emoji { font-size: 2rem; }
+
+.event-badge {
+ font-size: 0.75rem;
+ padding: 4px 10px;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 20px;
+ color: var(--text-secondary);
+}
+
+.event-badge.online {
+ background: rgba(78, 205, 196, 0.12);
+ color: var(--accent-3);
+}
+
+.event-card h3 {
+ font-size: 1.05rem;
+ margin-bottom: 6px;
+}
+
+.event-date {
+ font-size: 0.85rem;
+ color: var(--accent-2);
+ margin-bottom: 8px;
+}
+
+.event-desc {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ line-height: 1.5;
+ margin-bottom: 14px;
+}
+
+/* ===== Legal / Privacy ===== */
+.legal-page {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: calc(var(--nav-height) + 40px) 24px 80px;
+ position: relative;
+ z-index: 1;
+}
+
+.legal-content .section-tag { margin-bottom: 12px; display: inline-block; }
+
+.legal-content h1 {
+ font-size: 2rem;
+ margin-bottom: 8px;
+}
+
+.legal-updated {
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ margin-bottom: 32px;
+}
+
+.legal-content section {
+ margin-bottom: 28px;
+}
+
+.legal-content h2 {
+ font-size: 1.2rem;
+ margin-bottom: 10px;
+ color: var(--accent-3);
+}
+
+.legal-content p {
+ color: var(--text-secondary);
+ line-height: 1.7;
+}
+
+.legal-content a { color: var(--accent-3); }
+
+@media (max-width: 768px) {
+ .flight-card { grid-template-columns: 1fr; }
+ .spin-card { padding: 28px 20px; }
+}
diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx
new file mode 100644
index 0000000..4ed26e7
--- /dev/null
+++ b/frontend/src/app/privacy/page.tsx
@@ -0,0 +1,54 @@
+import Link from "next/link";
+import type { Metadata } from "next";
+import SiteShell from "@/components/SiteShell";
+
+export const metadata: Metadata = {
+ title: "隐私政策 · NomadFlow",
+ description: "NomadFlow 隐私政策与 Cookie 使用说明",
+};
+
+export default function PrivacyPage() {
+ return (
+
+
+
+
+ 🔒 PRIVACY
+ 隐私政策
+ 最后更新:2026-08-28
+
+
+ 我们收集什么
+
+ NomadFlow 可能收集你主动提供的信息(如邮箱订阅、注册账号),以及必要的技术信息(浏览器类型、访问页面)用于改进体验。
+
+
+
+
+ Cookie 使用
+
+ 我们使用 Cookie / localStorage 记住主题偏好、行程规划、行李清单勾选状态与 Cookie 同意选择。这些数据仅保存在你的设备本地,不会上传用于广告追踪。
+
+
+
+
+ 第三方服务
+
+ 网站可能嵌入图表 CDN、地图相关资源等第三方脚本。请参考对应服务商的隐私政策。我们不会出售你的个人信息。
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts
index 42705ee..9e61b0e 100644
--- a/frontend/src/app/sitemap.ts
+++ b/frontend/src/app/sitemap.ts
@@ -10,6 +10,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
+ { url: `${SITE}/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
+ { url: `${SITE}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.4 },
...DESTINATIONS.map((slug) => ({
url: `${SITE}/destinations/${slug}`,
lastModified: now,
diff --git a/frontend/src/components/CookieConsent.tsx b/frontend/src/components/CookieConsent.tsx
index ec25d2e..8b76cab 100644
--- a/frontend/src/components/CookieConsent.tsx
+++ b/frontend/src/components/CookieConsent.tsx
@@ -26,7 +26,7 @@ export default function CookieConsent() {
🍪
我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。
- 了解更多
+ 隐私政策
diff --git a/frontend/src/components/FlightCost.tsx b/frontend/src/components/FlightCost.tsx
new file mode 100644
index 0000000..026015b
--- /dev/null
+++ b/frontend/src/components/FlightCost.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+/** 粗略往返机票估算(CNY),基于城市对距离感 */
+const FLIGHT_BASE: Record = {
+ bali: 2800, lisbon: 6500, chiangmai: 2200,
+ mexico: 7800, barcelona: 6200, tokyo: 3500,
+};
+
+const FROM_CITIES = [
+ { key: "beijing", label: "🇨🇳 北京", factor: 1 },
+ { key: "shanghai", label: "🇨🇳 上海", factor: 0.95 },
+ { key: "guangzhou", label: "🇨🇳 广州", factor: 0.9 },
+ { key: "chengdu", label: "🇨🇳 成都", factor: 1.05 },
+ { key: "hongkong", label: "🇭🇰 香港", factor: 0.85 },
+];
+
+const SEASON_FACTOR = [
+ { key: "low", label: "🍃 淡季", factor: 0.75 },
+ { key: "normal", label: "🌤️ 平季", factor: 1 },
+ { key: "peak", label: "🔥 旺季", factor: 1.45 },
+];
+
+interface Props {
+ destinations: Destination[];
+}
+
+export default function FlightCost({ destinations }: Props) {
+ const [from, setFrom] = useState("shanghai");
+ const [to, setTo] = useState(destinations[0]?.slug || "bali");
+ const [season, setSeason] = useState("normal");
+ const [passengers, setPassengers] = useState(1);
+
+ const estimate = useMemo(() => {
+ const base = FLIGHT_BASE[to] ?? 4000;
+ const fromF = FROM_CITIES.find((c) => c.key === from)?.factor ?? 1;
+ const seasonF = SEASON_FACTOR.find((s) => s.key === season)?.factor ?? 1;
+ const oneWay = Math.round(base * fromF * seasonF);
+ const roundTrip = oneWay * 2;
+ const total = roundTrip * passengers;
+ return { oneWay, roundTrip, total };
+ }, [from, to, season, passengers]);
+
+ const dest = destinations.find((d) => d.slug === to);
+
+ return (
+
+
+
+
✈️ FLIGHT
+
机票预算估算
+
粗略估算往返机票,帮你把旅居启动成本算清楚
+
+
+
+
+
+
+
+
+
→
+
+
+
+
+
+
+
+ {SEASON_FACTOR.map((s) => (
+
+ ))}
+
+
+
+
+ setPassengers(+e.target.value)}
+ />
+
+
+
+
+
+ {FROM_CITIES.find((c) => c.key === from)?.label}
+ ✈️
+ {dest?.emoji} {dest?.name}
+
+
+
+ 单程参考
+ ¥{estimate.oneWay.toLocaleString()}
+
+
+ 往返参考
+ ¥{estimate.roundTrip.toLocaleString()}
+
+
+ 总计 ({passengers}人)
+ ¥{estimate.total.toLocaleString()}
+
+
+
* 估算仅供参考,实际票价随航司与预订时间波动较大
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
index 474622c..912bba7 100644
--- a/frontend/src/components/Footer.tsx
+++ b/frontend/src/components/Footer.tsx
@@ -20,6 +20,7 @@ export default function Footer() {
探索
目的地
+
命运转盘
省钱对比
费用计算器
签证指南
@@ -28,7 +29,9 @@ export default function Footer() {
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx
index 6b52c84..55f2fd8 100644
--- a/frontend/src/components/HomeClient.tsx
+++ b/frontend/src/components/HomeClient.tsx
@@ -34,6 +34,9 @@ import CoworkingGuide from "./CoworkingGuide";
import PackingChecklist from "./PackingChecklist";
import SeasonGuide from "./SeasonGuide";
import NomadTips from "./NomadTips";
+import SpinGlobe from "./SpinGlobe";
+import FlightCost from "./FlightCost";
+import NomadEvents from "./NomadEvents";
import KeyboardShortcuts from "./KeyboardShortcuts";
import BackToTop from "./BackToTop";
@@ -103,10 +106,12 @@ export default function HomeClient(props: Props) {
setMatcherOpen(true)} />
+
+
@@ -115,6 +120,7 @@ export default function HomeClient(props: Props) {
+
diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx
index 2b13221..e27282b 100644
--- a/frontend/src/components/KeyboardShortcuts.tsx
+++ b/frontend/src/components/KeyboardShortcuts.tsx
@@ -9,6 +9,7 @@ interface Props {
const SHORTCUTS = [
{ keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
+ { keys: ["G"], label: "命运转盘", icon: "🎲" },
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
@@ -31,6 +32,10 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
e.preventDefault();
onOpenMatcher?.();
}
+ if (e.key === "g" && !typing && !e.metaKey && !e.ctrlKey) {
+ e.preventDefault();
+ document.getElementById("spin")?.scrollIntoView({ behavior: "smooth" });
+ }
if (e.key === "Escape" && open) setOpen(false);
};
window.addEventListener("keydown", onKey);
diff --git a/frontend/src/components/NomadEvents.tsx b/frontend/src/components/NomadEvents.tsx
new file mode 100644
index 0000000..2c91ac7
--- /dev/null
+++ b/frontend/src/components/NomadEvents.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+interface EventItem {
+ id: string;
+ emoji: string;
+ title: string;
+ city: string;
+ date: string;
+ type: string;
+ desc: string;
+ online: boolean;
+}
+
+const EVENTS: EventItem[] = [
+ {
+ id: "1", emoji: "☕", title: "清迈咖啡馆 Meetup", city: "清迈",
+ date: "2026-09-12", type: "meetup", online: false,
+ desc: "每周五下午,Punspace 门口集合,结识本地游民与远程开发者。",
+ },
+ {
+ id: "2", emoji: "🏄", title: "巴厘岛冲浪 + Cowork 日", city: "巴厘岛",
+ date: "2026-09-20", type: "lifestyle", online: false,
+ desc: "上午 Canggu 冲浪,下午 Hubud 联合办公,傍晚沙滩社交。",
+ },
+ {
+ id: "3", emoji: "💻", title: "远程工作效率工作坊", city: "线上",
+ date: "2026-09-08", type: "workshop", online: true,
+ desc: "时区管理、异步协作与深度工作方法分享,Zoom 直播。",
+ },
+ {
+ id: "4", emoji: "🍷", title: "里斯本游民之夜", city: "里斯本",
+ date: "2026-09-27", type: "meetup", online: false,
+ desc: "Alfama 区酒窖聚会,葡萄牙 D7 签证经验交流。",
+ },
+ {
+ id: "5", emoji: "🗺️", title: "签证政策 AMA", city: "线上",
+ date: "2026-10-05", type: "workshop", online: true,
+ desc: "移民顾问在线答疑:西班牙 Nomad Visa、泰国 LTR、印尼 B211A。",
+ },
+ {
+ id: "6", emoji: "🌮", title: "墨西哥城美食徒步", city: "墨西哥城",
+ date: "2026-10-11", type: "lifestyle", online: false,
+ desc: "Roma Norte 街头美食 + 共享办公空间探访。",
+ },
+];
+
+const FILTERS = [
+ { key: "all", label: "🌏 全部" },
+ { key: "meetup", label: "🤝 聚会" },
+ { key: "workshop", label: "📚 工作坊" },
+ { key: "lifestyle", label: "🌴 生活方式" },
+ { key: "online", label: "💻 线上" },
+];
+
+export default function NomadEvents() {
+ const [filter, setFilter] = useState("all");
+ const [joined, setJoined] = useState>({});
+
+ const filtered = useMemo(() => {
+ if (filter === "all") return EVENTS;
+ if (filter === "online") return EVENTS.filter((e) => e.online);
+ return EVENTS.filter((e) => e.type === filter);
+ }, [filter]);
+
+ const toggleJoin = (id: string) => {
+ setJoined((prev) => ({ ...prev, [id]: !prev[id] }));
+ };
+
+ return (
+
+
+
+
📅 EVENTS
+
游民活动日历
+
线下 Meetup、线上工作坊,找到你的下一场社交
+
+
+
+ {FILTERS.map((f) => (
+
+ ))}
+
+
+
+ {filtered.map((e, i) => (
+
+
+ {e.emoji}
+
+ {e.online ? "💻 线上" : `📍 ${e.city}`}
+
+
+
{e.title}
+
📅 {e.date}
+
{e.desc}
+
+
+ ))}
+
+ {filtered.length === 0 && (
+
该分类暂无活动
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/SectionNav.tsx b/frontend/src/components/SectionNav.tsx
index ce6d46c..445524e 100644
--- a/frontend/src/components/SectionNav.tsx
+++ b/frontend/src/components/SectionNav.tsx
@@ -5,15 +5,18 @@ import { useEffect, useState } from "react";
const SECTIONS = [
{ id: "map", emoji: "🗺️", label: "地图" },
{ id: "destinations", emoji: "🌍", label: "目的地" },
+ { id: "spin", emoji: "🎲", label: "转盘" },
{ id: "timezone", emoji: "🕐", label: "时区" },
{ id: "trip", emoji: "🗓️", label: "行程" },
{ id: "calculator", emoji: "🧮", label: "计算器" },
{ id: "cost-compare", emoji: "💰", label: "省钱" },
+ { id: "flight", emoji: "✈️", label: "机票" },
{ id: "lifestyle", emoji: "💻", label: "生活" },
{ id: "visa", emoji: "📋", label: "签证" },
{ id: "coworking", emoji: "💻", label: "办公" },
{ id: "season", emoji: "🌤️", label: "季节" },
{ id: "packing", emoji: "🧳", label: "行李" },
+ { id: "events", emoji: "📅", label: "活动" },
{ id: "blog", emoji: "📝", label: "博客" },
];
diff --git a/frontend/src/components/SpinGlobe.tsx b/frontend/src/components/SpinGlobe.tsx
new file mode 100644
index 0000000..959451a
--- /dev/null
+++ b/frontend/src/components/SpinGlobe.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useRef, useState } from "react";
+import Link from "next/link";
+import type { Destination } from "@/lib/types";
+
+interface Props {
+ destinations: Destination[];
+}
+
+export default function SpinGlobe({ destinations }: Props) {
+ const [spinning, setSpinning] = useState(false);
+ const [result, setResult] = useState(null);
+ const [display, setDisplay] = useState(null);
+ const timerRef = useRef | null>(null);
+
+ const spin = () => {
+ if (spinning || destinations.length === 0) return;
+ setSpinning(true);
+ setResult(null);
+
+ let ticks = 0;
+ const total = 18 + Math.floor(Math.random() * 8);
+ const winner = destinations[Math.floor(Math.random() * destinations.length)];
+
+ timerRef.current = setInterval(() => {
+ ticks++;
+ setDisplay(destinations[Math.floor(Math.random() * destinations.length)]);
+ if (ticks >= total) {
+ if (timerRef.current) clearInterval(timerRef.current);
+ setDisplay(winner);
+ setResult(winner);
+ setSpinning(false);
+ }
+ }, 90);
+ };
+
+ const shown = display || destinations[0];
+
+ return (
+
+
+
+
🎲 SPIN
+
命运转盘 · 下一站去哪?
+
不知道去哪?让命运帮你选一座游民城市
+
+
+
+
+
{shown?.emoji || "🌍"}
+
+
+
+
+
+ {shown ? `${shown.name}, ${shown.country}` : "准备就绪"}
+
+ {shown && !spinning && !result && (
+
点击下方按钮,开启随机旅居冒险
+ )}
+ {spinning &&
正在穿越时区… ✈️
}
+
+ {result && !spinning && (
+
+
🎯 命运指引你去
+
+ 💰 ¥{result.cost.toLocaleString()}/月
+ 📶 {result.speed}Mbps
+ ⭐ {result.rating}
+
+
{result.description}
+
+
+ 查看详情 →
+
+
+
+
+ )}
+
+ {!result && (
+
+ )}
+
+
+
+ );
+}