Add account-synced trip plans with durable auth store and richer profile hub.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-29 03:10:29 -05:00
parent fd87f765c8
commit a1b2408ed0
17 changed files with 710 additions and 85 deletions

3
.gitignore vendored
View File

@ -6,6 +6,9 @@ backend/**/__pycache__/
backend/.env
frontend/.env.local
# Auth / plan user store (runtime)
backend/app/data/user_store.json
# PocketBase
pocketbase/pb_data/*
!pocketbase/pb_data/.gitkeep

View File

@ -136,7 +136,7 @@ docker compose up -d
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 |
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出;登录后云端同步 |
| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 |
| 📜 更新日志 | `/changelog` 迭代记录 |
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |

View File

@ -3,10 +3,11 @@ from fastapi import APIRouter, Header, HTTPException
from app.schemas import (
AuthLogin, AuthRegister, AuthResponse, Destination,
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
UserPlanPayload,
)
from app.services.auth import (
DEMO_TOKEN, get_favorites, get_user_by_token,
login_user, register_user, toggle_favorite,
get_favorites, get_plan, get_user_by_token,
login_demo, login_user, register_user, save_plan, toggle_favorite,
)
from app.services.pocketbase import pb
@ -41,10 +42,10 @@ async def me(authorization: str | None = Header(None)):
@router.get("/demo", response_model=AuthResponse)
async def demo_login():
"""一键体验演示账号"""
user = get_user_by_token(DEMO_TOKEN)
if not user:
result = login_demo()
if not result:
raise HTTPException(500, "演示账号不可用")
return AuthResponse(token=DEMO_TOKEN, user=UserProfile(**user))
return AuthResponse(**result)
@router.get("/favorites", response_model=FavoriteResponse)
@ -73,11 +74,14 @@ async def profile_stats(authorization: str | None = Header(None)):
if not user:
raise HTTPException(401, "未登录")
favs = get_favorites(token)
plan = get_plan(token) or {}
cities = len(plan.get("items") or [])
levels = [(0, "🌱 新手游民"), (1, "🎒 背包客"), (2, "✈️ 飞行游民"), (3, "🌍 环球游民")]
level = levels[min(len(favs), 3)][1]
explored = max(len(favs), cities)
level = levels[min(explored, 3)][1]
return ProfileStats(
favorites_count=len(favs),
destinations_explored=len(favs),
destinations_explored=explored,
member_since="2026",
nomad_level=level,
)
@ -95,6 +99,33 @@ async def add_favorite(body: FavoriteRequest, authorization: str | None = Header
return FavoriteResponse(slugs=slugs)
@router.get("/plan", response_model=UserPlanPayload)
async def read_plan(authorization: str | None = Header(None)):
token = _extract_token(authorization)
if not get_user_by_token(token):
raise HTTPException(401, "未登录")
plan = get_plan(token)
if not plan:
raise HTTPException(401, "未登录")
return UserPlanPayload(**plan)
@router.put("/plan", response_model=UserPlanPayload)
async def write_plan(body: UserPlanPayload, authorization: str | None = Header(None)):
token = _extract_token(authorization)
if not get_user_by_token(token):
raise HTTPException(401, "未登录")
saved = save_plan(
token,
[item.model_dump() for item in body.items],
body.meta.model_dump(),
body.updated_at or None,
)
if not saved:
raise HTTPException(401, "未登录")
return UserPlanPayload(**saved)
def _extract_token(authorization: str | None) -> str:
if not authorization:
return ""

View File

@ -156,9 +156,32 @@ class ProfileStats(BaseModel):
nomad_level: str
class TripItemModel(BaseModel):
slug: str
name: str
country: str
emoji: str
cost: float
months: int = 1
note: str = ""
class PlanMetaModel(BaseModel):
title: str = "我的旅居计划"
startMonth: str = ""
monthlyBudget: float = 0
checklist: dict[str, bool] = Field(default_factory=dict)
class UserPlanPayload(BaseModel):
items: list[TripItemModel] = Field(default_factory=list)
meta: PlanMetaModel = Field(default_factory=PlanMetaModel)
updated_at: int = 0
class SearchResult(BaseModel):
type: str # destination | blog | visa | faq
title: str
subtitle: str
emoji: str
url: str
url: str

View File

@ -1,20 +1,73 @@
"""Simple auth service with in-memory fallback when PocketBase users unavailable."""
"""Auth + favorites + synced user plans. File-backed so demo restarts keep data."""
from __future__ import annotations
import hashlib
import json
import secrets
import time
from pathlib import Path
from typing import Any
from app.data import mock_data
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "user_store.json"
# In-memory store for demo mode
_users: dict[str, dict] = {}
_sessions: dict[str, str] = {} # token -> user_id
_favorites: dict[str, list[str]] = {} # user_id -> [slugs]
_plans: dict[str, dict] = {} # user_id -> { items, meta, updated_at }
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def _persist() -> None:
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = {
"users": _users,
"sessions": _sessions,
"favorites": _favorites,
"plans": _plans,
}
STORE_PATH.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def _restore() -> bool:
if not STORE_PATH.exists():
return False
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
global _users, _sessions, _favorites, _plans
_users = data.get("users") or {}
_sessions = data.get("sessions") or {}
_favorites = data.get("favorites") or {}
_plans = data.get("plans") or {}
return True
def _user_profile(user: dict) -> dict:
return {
"id": user["id"],
"email": user["email"],
"name": user["name"],
"avatar": user.get("avatar", "🧑‍💻"),
}
def _empty_plan() -> dict[str, Any]:
return {
"items": [],
"meta": {
"title": "我的旅居计划",
"startMonth": "",
"monthlyBudget": 0,
"checklist": {},
},
"updated_at": 0,
}
def register_user(email: str, password: str, name: str) -> dict[str, Any] | None:
if email in _users:
return None
@ -27,8 +80,10 @@ def register_user(email: str, password: str, name: str) -> dict[str, Any] | None
"avatar": "🧑‍💻",
}
_favorites[uid] = []
_plans[uid] = _empty_plan()
token = secrets.token_urlsafe(32)
_sessions[token] = uid
_persist()
return {"token": token, "user": _user_profile(_users[email])}
@ -38,6 +93,19 @@ def login_user(email: str, password: str) -> dict[str, Any] | None:
return None
token = secrets.token_urlsafe(32)
_sessions[token] = user["id"]
_persist()
return {"token": token, "user": _user_profile(user)}
def login_demo() -> dict[str, Any] | None:
email = "demo@nomadro.com"
if email not in _users:
created = register_user(email, "demo123", "演示用户")
return created
user = _users[email]
token = secrets.token_urlsafe(32)
_sessions[token] = user["id"]
_persist()
return {"token": token, "user": _user_profile(user)}
@ -55,7 +123,7 @@ def get_favorites(token: str) -> list[str]:
uid = _sessions.get(token)
if not uid:
return []
return _favorites.get(uid, [])
return list(_favorites.get(uid, []))
def toggle_favorite(token: str, slug: str) -> list[str]:
@ -67,17 +135,38 @@ def toggle_favorite(token: str, slug: str) -> list[str]:
favs.remove(slug)
else:
favs.append(slug)
return favs
_persist()
return list(favs)
def _user_profile(user: dict) -> dict:
def get_plan(token: str) -> dict[str, Any] | None:
uid = _sessions.get(token)
if not uid:
return None
plan = _plans.get(uid) or _empty_plan()
return {
"id": user["id"],
"email": user["email"],
"name": user["name"],
"avatar": user.get("avatar", "🧑‍💻"),
"items": plan.get("items") or [],
"meta": plan.get("meta") or _empty_plan()["meta"],
"updated_at": int(plan.get("updated_at") or 0),
}
# Demo account
_demo = register_user("demo@nomadro.com", "demo123", "演示用户")
DEMO_TOKEN = _demo["token"] if _demo else ""
def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None:
uid = _sessions.get(token)
if not uid:
return None
ts = int(updated_at or time.time() * 1000)
_plans[uid] = {
"items": items,
"meta": meta,
"updated_at": ts,
}
_persist()
return get_plan(token)
# Boot: restore disk store or seed demo
if not _restore():
login_demo()
elif "demo@nomadro.com" not in _users:
login_demo()

View File

@ -8,6 +8,16 @@ export const metadata: Metadata = {
};
const LOGS = [
{
date: "2026-08-29",
tag: "账号同步",
items: [
"登录后旅居计划(城市+预算+清单)云端同步,本地与账号自动合并",
"账号与收藏/计划落盘持久化,服务重启不丢演示数据",
"个人中心升级为计划仪表盘:就绪度、预算、收藏写入计划、行程对比",
"目的地详情:停留月数、签证提醒、预算对照、同区对比",
],
},
{
date: "2026-08-29",
tag: "决策闭环",

View File

@ -9095,3 +9095,75 @@ img { max-width: 100%; display: block; }
.compare-modal-actions .btn,
.compare-page-actions .btn { flex: 1; }
}
/* ===== Plan cloud sync + profile hub ===== */
.plan-sync-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin: 0 0 16px;
}
.plan-sync-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
font-size: 0.8rem;
border: var(--border-glass);
color: var(--text-secondary);
text-decoration: none;
}
.plan-sync-pill.synced { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); }
.plan-sync-pill.syncing { color: var(--accent-3, #FFE66D); }
.plan-sync-pill.offline { color: var(--accent-1); }
.profile-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.profile-section-head h2 { margin: 0; }
.profile-plan-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid color-mix(in srgb, var(--text-secondary) 18%, transparent);
}
.profile-trip-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.dest-months-pick {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--text-secondary);
padding: 8px 10px;
border-radius: var(--radius-md);
border: var(--border-glass);
}
.dest-months-pick input {
width: 48px;
padding: 4px 6px;
border-radius: 6px;
border: var(--border-glass);
background: transparent;
color: var(--text-primary);
}
.dest-plan-hints {
margin: 12px 0 4px;
display: flex;
flex-direction: column;
gap: 6px;
}

View File

@ -9,6 +9,10 @@ import type { Destination, ProfileStats, TripItem } from "@/lib/types";
import SiteShell from "@/components/SiteShell";
import FavoriteButton from "@/components/FavoriteButton";
import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta";
import { PLAN_SYNC_EVENT } from "@/lib/planSync";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useToast } from "@/lib/toast";
interface Badge {
emoji: string;
@ -17,26 +21,33 @@ interface Badge {
earned: boolean;
}
function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number): Badge[] {
function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number, readiness: number): Badge[] {
const tripMonths = trip.reduce((s, t) => s + t.months, 0);
return [
{ emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true },
{ emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 },
{ emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 },
{ emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 },
{ emoji: "✅", label: "出发就绪", desc: "就绪清单 ≥ 50%", earned: readiness >= 50 },
{ emoji: "🌍", label: "环球游民", desc: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 },
{ emoji: "👑", label: "资深游民", desc: "2024 年前加入", earned: parseInt(stats?.member_since ?? "2026") <= 2024 },
];
}
export default function ProfilePage() {
const { user, token, favorites, logout } = useAuth();
const { user, token, favorites, logout, planSyncStatus } = useAuth();
const router = useRouter();
const { toast } = useToast();
const [stats, setStats] = useState<ProfileStats | null>(null);
const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]);
const [trip, setTrip] = useState<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta | null>(null);
const [loading, setLoading] = useState(true);
const refreshPlan = () => {
setTrip(loadTrip<TripItem>());
setMeta(loadPlanMeta());
};
useEffect(() => {
if (!token) {
router.push("/login");
@ -51,12 +62,36 @@ export default function ProfilePage() {
setFavoriteDests(f);
}).catch(() => {}).finally(() => setLoading(false));
setTrip(loadTrip<TripItem>());
refreshPlan();
window.addEventListener(PLAN_SYNC_EVENT, refreshPlan);
return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan);
}, [token, favorites, router]);
const badges = useMemo(() => getBadges(stats, trip, favoriteDests.length), [stats, trip, favoriteDests.length]);
const totalMonths = trip.reduce((s, t) => s + t.months, 0);
const totalCost = trip.reduce((s, t) => s + t.cost * t.months, 0);
const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0;
const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length;
const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100);
const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget;
const badges = useMemo(
() => getBadges(stats, trip, favoriteDests.length, readiness),
[stats, trip, favoriteDests.length, readiness]
);
const earnedCount = badges.filter((b) => b.earned).length;
const favsNotInTrip = favoriteDests.filter((d) => !trip.some((t) => t.slug === d.slug));
const addFavsToPlan = () => {
if (favsNotInTrip.length === 0) {
toast("收藏城市都已在计划中", "info");
return;
}
const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1);
refreshPlan();
toast(`已将 ${added} 座收藏城市写入计划`);
};
if (!user) return null;
return (
@ -68,6 +103,12 @@ export default function ProfilePage() {
<h1>{user.name}</h1>
<p>{user.email}</p>
{stats && <span className="profile-level">{stats.nomad_level}</span>}
<span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}>
{planSyncStatus === "syncing" && "计划同步中…"}
{planSyncStatus === "synced" && "✓ 计划已云端同步"}
{planSyncStatus === "offline" && "计划仅本机"}
{planSyncStatus === "idle" && "账号已登录"}
</span>
</div>
<button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}>
退出登录
@ -82,14 +123,14 @@ export default function ProfilePage() {
<span>收藏目的地</span>
</div>
<div className="profile-stat-card">
<span className="stat-emoji">🗺️</span>
<strong>{stats.destinations_explored}</strong>
<span>探索城市</span>
<span className="stat-emoji">🗓️</span>
<strong>{trip.length}</strong>
<span>计划城市</span>
</div>
<div className="profile-stat-card">
<span className="stat-emoji">📅</span>
<strong>{stats.member_since}</strong>
<span>加入年份</span>
<span className="stat-emoji">✅</span>
<strong>{readiness}%</strong>
<span>出发就绪</span>
</div>
<div className="profile-stat-card">
<span className="stat-emoji">🏅</span>
@ -99,6 +140,60 @@ export default function ProfilePage() {
</div>
)}
<section className="profile-trip">
<div className="profile-section-head">
<h2>🗓️ {meta?.title || "我的旅居计划"}</h2>
<Link href="/plan" className="btn btn-primary btn-sm">打开计划中心</Link>
</div>
{trip.length === 0 ? (
<div className="profile-empty">
<span style={{ fontSize: "2.5rem" }}>🗺️</span>
<p>还没有规划行程</p>
<Link href="/plan" className="btn btn-primary">去规划 →</Link>
</div>
) : (
<div className="profile-trip-list">
{(meta?.startMonth || meta?.monthlyBudget) && (
<div className="profile-plan-meta">
{meta?.startMonth && <span>出发 {meta.startMonth}</span>}
{meta && meta.monthlyBudget > 0 && (
<span className={!budgetOk ? "plan-warn-text" : undefined}>
月预算 ¥{meta.monthlyBudget.toLocaleString()}
{avgMonth > 0 && ` · 均月 ¥${avgMonth.toLocaleString()}`}
</span>
)}
<span>就绪 {checked}/{MOVE_CHECKLIST.length}</span>
</div>
)}
{trip.map((t, i) => (
<div key={t.slug} className="profile-trip-item">
<span className="trip-item-num">{i + 1}</span>
<span>{t.emoji}</span>
<div>
<strong>{t.name}, {t.country}</strong>
<span>{t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()}</span>
</div>
</div>
))}
<div className="profile-trip-total">
总计 <strong>{totalMonths} 个月</strong> ·
<strong className="gradient-text"> ¥{totalCost.toLocaleString()}</strong>
</div>
<div className="profile-trip-actions">
{trip.length >= 2 && (
<Link
href={`/compare?cities=${trip.map((t) => t.slug).slice(0, 4).join(",")}`}
className="btn btn-ghost btn-sm"
>
对比行程城市
</Link>
)}
<Link href="/plan" className="btn btn-ghost btn-sm">继续编辑 →</Link>
</div>
</div>
)}
</section>
<section className="profile-badges">
<h2>🏅 游民成就</h2>
<div className="badge-grid">
@ -113,7 +208,14 @@ export default function ProfilePage() {
</section>
<section className="profile-favorites">
<h2>❤️ 我的收藏</h2>
<div className="profile-section-head">
<h2>❤️ 我的收藏</h2>
{favsNotInTrip.length > 0 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={addFavsToPlan}>
收藏写入计划({favsNotInTrip.length})
</button>
)}
</div>
{loading ? (
<p className="profile-loading">加载中...</p>
) : favoriteDests.length === 0 ? (
@ -138,44 +240,15 @@ export default function ProfilePage() {
)}
</section>
<section className="profile-trip">
<h2>🗓️ 我的行程</h2>
{trip.length === 0 ? (
<div className="profile-empty">
<span style={{ fontSize: "2.5rem" }}>🗺️</span>
<p>还没有规划行程</p>
<Link href="/plan" className="btn btn-primary">去规划 →</Link>
</div>
) : (
<div className="profile-trip-list">
{trip.map((t, i) => (
<div key={t.slug} className="profile-trip-item">
<span className="trip-item-num">{i + 1}</span>
<span>{t.emoji}</span>
<div>
<strong>{t.name}, {t.country}</strong>
<span>{t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()}</span>
</div>
</div>
))}
<div className="profile-trip-total">
总计 <strong>{trip.reduce((s, t) => s + t.months, 0)} 个月</strong> ·
<strong className="gradient-text"> ¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()}</strong>
</div>
<Link href="/plan" className="btn btn-ghost">打开旅居计划中心 →</Link>
</div>
)}
</section>
<section className="profile-quick">
<h2>🚀 快捷入口</h2>
<div className="profile-quick-grid">
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
<Link href="/plan" className="quick-card">🗓️ 旅居计划</Link>
<Link href="/compare" className="quick-card">⚖️ 城市对比</Link>
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
<Link href="/#coworking" className="quick-card">💻 联合办公</Link>
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
<Link href="/#nomad-score" className="quick-card">📊 就绪度测评</Link>
<Link href="/tools" className="quick-card">🛠️ 工具箱</Link>
</div>
</section>
</div>

View File

@ -1,9 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useToast } from "@/lib/toast";
import { loadTrip, saveTrip } from "@/lib/tripStorage";
import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, stayHint } from "@/lib/planMeta";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { compareUrl } from "@/lib/compareScore";
import type { Destination, TripItem } from "@/lib/types";
const TZ_LABELS: Record<string, string> = {
@ -30,7 +33,11 @@ function getScores(d: Destination) {
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
const { toast } = useToast();
const [added, setAdded] = useState(false);
const [months, setMonths] = useState(1);
const scores = getScores(dest);
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
const overBudget = budget > 0 && dest.cost > budget;
const related = allDestinations
.filter((d) => d.region === dest.region && d.slug !== dest.slug)
@ -40,12 +47,17 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const trip = loadTrip<TripItem>();
if (trip.some((t) => t.slug === dest.slug)) {
toast("该城市已在行程中", "info");
setAdded(true);
return;
}
trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1, note: "" });
saveTrip(trip);
const { added: n } = mergeDestinationsIntoTrip([dest], months);
setAdded(true);
toast(`${dest.emoji} ${dest.name} 已加入旅居计划`);
toast(
n > 0
? `${dest.emoji} ${dest.name} 已加入旅居计划(${months} 个月)`
: "该城市已在计划中",
n > 0 ? "success" : "info"
);
};
const share = async () => {
@ -69,13 +81,47 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{added ? (
<Link href="/plan" className="btn btn-primary">✓ 已在计划中 · 打开 →</Link>
) : (
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
<>
<label className="dest-months-pick">
停留
<input
type="number"
min={1}
max={24}
value={months}
onChange={(e) => setMonths(Math.max(1, Math.min(24, +e.target.value || 1)))}
/>
月
</label>
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
</>
)}
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
{related.length > 0 && (
<Link
href={compareUrl([dest.slug, ...related.slice(0, 2).map((d) => d.slug)])}
className="btn btn-ghost"
>
⚖️ 对比同区
</Link>
)}
<Link href="/plan" className="btn btn-ghost">🗓️ 计划中心</Link>
<Link href={`/#calculator`} className="btn btn-ghost">🧮 算费用</Link>
<Link href="/#visa" className="btn btn-ghost">📋 签证</Link>
</div>
{(visa || overBudget) && (
<div className="dest-plan-hints">
{visa && (
<p className={`plan-visa${visa.overLimit ? " warn" : ""}`}>🛂 {visa.text}</p>
)}
{overBudget && (
<p className="plan-visa warn">
💰 月生活费 ¥{dest.cost.toLocaleString()} 高于你的计划月预算 ¥{budget.toLocaleString()}
</p>
)}
</div>
)}
<div className="dest-detail-extras">
<div className="dest-radar-card">
<h3>📊 城市画像</h3>

View File

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useToast } from "@/lib/toast";
import { useAuth } from "@/lib/auth";
import { loadTrip, saveTrip } from "@/lib/tripStorage";
import {
loadPlanMeta,
@ -12,6 +13,7 @@ import {
MOVE_CHECKLIST,
type PlanMeta,
} from "@/lib/planMeta";
import { PLAN_SYNC_EVENT, pushPlanNow } from "@/lib/planSync";
import type { Destination, TripItem } from "@/lib/types";
interface Props {
@ -31,6 +33,7 @@ function scoreDest(d: Destination) {
export default function MovePlanClient({ destinations }: Props) {
const { toast } = useToast();
const { token, user, planSyncStatus } = useAuth();
const [trip, setTrip] = useState<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta>(() => ({
title: "我的旅居计划",
@ -62,6 +65,15 @@ export default function MovePlanClient({ destinations }: Props) {
setReady(true);
}, [toast]);
useEffect(() => {
const refresh = () => {
setTrip(loadTrip<TripItem>());
setMeta(loadPlanMeta());
};
window.addEventListener(PLAN_SYNC_EVENT, refresh);
return () => window.removeEventListener(PLAN_SYNC_EVENT, refresh);
}, []);
const persistTrip = (items: TripItem[]) => {
setTrip(items);
saveTrip(items);
@ -216,6 +228,31 @@ export default function MovePlanClient({ destinations }: Props) {
aria-label="计划标题"
/>
<p>把多城路线、预算、签证提醒和出发清单放在一页——真正能拿去执行的旅居计划。</p>
<div className="plan-sync-row">
{user ? (
<>
<span className={`plan-sync-pill ${planSyncStatus}`}>
{planSyncStatus === "syncing" && "同步中…"}
{planSyncStatus === "synced" && "✓ 已登录 · 计划云端同步"}
{planSyncStatus === "offline" && "离线 · 仅保存在本机"}
{planSyncStatus === "idle" && "已登录"}
</span>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={async () => {
if (!token) return;
const ok = await pushPlanNow(token);
toast(ok ? "已手动同步到账号" : "同步失败,请稍后重试", ok ? "success" : "error");
}}
>
立即同步
</button>
</>
) : (
<Link href="/login" className="plan-sync-pill idle">登录后跨设备同步计划 →</Link>
)}
</div>
<div className="plan-meta-row">
<label className="plan-meta-field">
<span>预计出发月</span>

View File

@ -1,6 +1,6 @@
import type {
AuthResponse, BlogPost, BlogPostDetail, ChartData, CostResult, Destination, FAQ,
ProfileStats, SearchResult, Stats, Testimonial, Tool, Visa,
ProfileStats, SearchResult, Stats, SyncedUserPlan, Testimonial, Tool, Visa,
} from "./types";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
@ -89,5 +89,15 @@ export const api = {
fetchAPI<ProfileStats>("/auth/profile/stats", {
headers: { Authorization: `Bearer ${token}` },
}),
getPlan: (token: string) =>
fetchAPI<SyncedUserPlan>("/auth/plan", {
headers: { Authorization: `Bearer ${token}` },
}),
savePlan: (token: string, plan: SyncedUserPlan) =>
fetchAPI<SyncedUserPlan>("/auth/plan", {
method: "PUT",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(plan),
}),
search: (q: string) => fetchAPI<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
};

View File

@ -2,12 +2,14 @@
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { api } from "@/lib/api";
import { syncPlanOnLogin } from "@/lib/planSync";
import type { UserProfile } from "@/lib/types";
interface AuthCtx {
user: UserProfile | null;
token: string | null;
favorites: string[];
planSyncStatus: "idle" | "syncing" | "synced" | "offline";
login: (email: string, password: string) => Promise<boolean>;
register: (email: string, password: string, name: string) => Promise<boolean>;
demoLogin: () => Promise<boolean>;
@ -22,6 +24,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<UserProfile | null>(null);
const [token, setToken] = useState<string | null>(null);
const [favorites, setFavorites] = useState<string[]>([]);
const [planSyncStatus, setPlanSyncStatus] = useState<"idle" | "syncing" | "synced" | "offline">("idle");
const loadFavorites = useCallback(async (t: string) => {
try {
@ -30,6 +33,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch { /* ignore */ }
}, []);
const reconcilePlan = useCallback(async (t: string) => {
setPlanSyncStatus("syncing");
try {
await syncPlanOnLogin(t);
setPlanSyncStatus("synced");
} catch {
setPlanSyncStatus("offline");
}
}, []);
useEffect(() => {
const saved = localStorage.getItem("nomad-token");
const savedUser = localStorage.getItem("nomad-user");
@ -37,21 +50,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setToken(saved);
setUser(JSON.parse(savedUser));
loadFavorites(saved);
void reconcilePlan(saved);
}
}, [loadFavorites]);
}, [loadFavorites, reconcilePlan]);
const persist = (t: string, u: UserProfile) => {
const persist = async (t: string, u: UserProfile) => {
setToken(t);
setUser(u);
localStorage.setItem("nomad-token", t);
localStorage.setItem("nomad-user", JSON.stringify(u));
loadFavorites(t);
await loadFavorites(t);
await reconcilePlan(t);
};
const login = async (email: string, password: string) => {
try {
const data = await api.login(email, password);
persist(data.token, data.user);
await persist(data.token, data.user);
return true;
} catch { return false; }
};
@ -59,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const register = async (email: string, password: string, name: string) => {
try {
const data = await api.register(email, password, name);
persist(data.token, data.user);
await persist(data.token, data.user);
return true;
} catch { return false; }
};
@ -67,7 +82,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const demoLogin = async () => {
try {
const data = await api.demoLogin();
persist(data.token, data.user);
await persist(data.token, data.user);
return true;
} catch { return false; }
};
@ -76,6 +91,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setToken(null);
setUser(null);
setFavorites([]);
setPlanSyncStatus("idle");
localStorage.removeItem("nomad-token");
localStorage.removeItem("nomad-user");
};
@ -90,7 +106,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return (
<AuthContext.Provider value={{
user, token, favorites, login, register, demoLogin, logout,
user, token, favorites, planSyncStatus, login, register, demoLogin, logout,
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
}}>
{children}

View File

@ -99,6 +99,13 @@ export function savePlanMeta(meta: PlanMeta) {
if (typeof window === "undefined") return;
localStorage.setItem(PRIMARY, JSON.stringify(meta));
localStorage.removeItem("nomadflow-plan-meta");
localStorage.setItem("nomadro-plan-updated", String(Date.now()));
try {
const token = localStorage.getItem("nomad-token");
if (token) {
import("./planSync").then((m) => m.schedulePlanSync(token)).catch(() => {});
}
} catch { /* ignore */ }
}
/** 由出发月 + 各站月数推算大致区间文案 */

View File

@ -0,0 +1,174 @@
import { api } from "@/lib/api";
import { loadPlanMeta, savePlanMeta, type PlanMeta, MOVE_CHECKLIST } from "@/lib/planMeta";
import { loadTrip, saveTrip } from "@/lib/tripStorage";
import type { TripItem } from "@/lib/types";
const UPDATED_KEY = "nomadro-plan-updated";
export const PLAN_SYNC_EVENT = "nomadro-plan-synced";
export interface SyncedPlan {
items: TripItem[];
meta: PlanMeta;
updated_at: number;
}
function localUpdatedAt(): number {
if (typeof window === "undefined") return 0;
return parseInt(localStorage.getItem(UPDATED_KEY) || "0", 10) || 0;
}
function setLocalUpdatedAt(ts: number) {
localStorage.setItem(UPDATED_KEY, String(ts));
}
export function readLocalPlan(): SyncedPlan {
return {
items: loadTrip<TripItem>(),
meta: loadPlanMeta(),
updated_at: localUpdatedAt(),
};
}
export function writeLocalPlan(plan: SyncedPlan) {
saveTrip(plan.items);
savePlanMeta(plan.meta);
setLocalUpdatedAt(plan.updated_at || Date.now());
window.dispatchEvent(new CustomEvent(PLAN_SYNC_EVENT));
}
function mergeItems(a: TripItem[], b: TripItem[]): TripItem[] {
const map = new Map<string, TripItem>();
for (const item of a) map.set(item.slug, { ...item });
for (const item of b) {
const prev = map.get(item.slug);
if (!prev) {
map.set(item.slug, { ...item });
continue;
}
map.set(item.slug, {
...prev,
...item,
months: Math.max(prev.months || 1, item.months || 1),
note: (item.note && item.note.length >= (prev.note || "").length) ? item.note : (prev.note || ""),
});
}
// preserve order: a first, then new from b
const order: string[] = [];
for (const item of a) if (!order.includes(item.slug)) order.push(item.slug);
for (const item of b) if (!order.includes(item.slug)) order.push(item.slug);
return order.map((s) => map.get(s)!).filter(Boolean);
}
function mergeMeta(a: PlanMeta, b: PlanMeta): PlanMeta {
const checklist: Record<string, boolean> = { ...a.checklist };
for (const [k, v] of Object.entries(b.checklist || {})) {
if (v) checklist[k] = true;
}
return {
title: (b.title && b.title !== "我的旅居计划") ? b.title : (a.title || b.title || "我的旅居计划"),
startMonth: b.startMonth || a.startMonth || "",
monthlyBudget: Math.max(a.monthlyBudget || 0, b.monthlyBudget || 0),
checklist,
};
}
function readiness(meta: PlanMeta): number {
const n = MOVE_CHECKLIST.filter((c) => meta.checklist[c.id]).length;
return Math.round((n / MOVE_CHECKLIST.length) * 100);
}
let pushTimer: ReturnType<typeof setTimeout> | null = null;
/** Debounced push of local plan to cloud when logged in. */
export function schedulePlanSync(token: string | null | undefined) {
if (!token || typeof window === "undefined") return;
if (pushTimer) clearTimeout(pushTimer);
pushTimer = setTimeout(() => {
void pushPlanNow(token);
}, 600);
}
export async function pushPlanNow(token: string): Promise<SyncedPlan | null> {
try {
const local = readLocalPlan();
const updated_at = Date.now();
setLocalUpdatedAt(updated_at);
const saved = await api.savePlan(token, {
items: local.items,
meta: local.meta,
updated_at,
});
window.dispatchEvent(new CustomEvent(PLAN_SYNC_EVENT));
return saved;
} catch {
return null;
}
}
export type SyncResult = "pulled" | "pushed" | "merged" | "noop";
/**
* On login: reconcile cloud ↔ local.
* - cloud empty + local data → push
* - local empty + cloud data → pull
* - both → merge cities/checklist, keep newer title/budget cues, then push
*/
export async function syncPlanOnLogin(token: string): Promise<SyncResult> {
try {
const cloud = await api.getPlan(token);
const local = readLocalPlan();
const localHas = local.items.length > 0 || readiness(local.meta) > 0 || !!local.meta.startMonth || local.meta.monthlyBudget > 0;
const cloudHas = cloud.items.length > 0 || readiness(cloud.meta) > 0 || !!cloud.meta.startMonth || cloud.meta.monthlyBudget > 0;
if (!cloudHas && localHas) {
await pushPlanNow(token);
return "pushed";
}
if (cloudHas && !localHas) {
writeLocalPlan({
items: cloud.items,
meta: cloud.meta,
updated_at: cloud.updated_at || Date.now(),
});
return "pulled";
}
if (!cloudHas && !localHas) return "noop";
// both have data
if (cloud.updated_at > local.updated_at + 1000 && local.updated_at > 0) {
// cloud clearly newer — still merge items so local draft cities aren't lost
const merged: SyncedPlan = {
items: mergeItems(cloud.items, local.items),
meta: mergeMeta(cloud.meta, local.meta),
updated_at: Date.now(),
};
writeLocalPlan(merged);
await api.savePlan(token, merged);
return "merged";
}
if (local.updated_at >= cloud.updated_at) {
const merged: SyncedPlan = {
items: mergeItems(local.items, cloud.items),
meta: mergeMeta(local.meta, cloud.meta),
updated_at: Date.now(),
};
writeLocalPlan(merged);
await api.savePlan(token, merged);
return local.updated_at === 0 ? "merged" : "pushed";
}
const merged: SyncedPlan = {
items: mergeItems(cloud.items, local.items),
meta: mergeMeta(cloud.meta, local.meta),
updated_at: Date.now(),
};
writeLocalPlan(merged);
await api.savePlan(token, merged);
return "merged";
} catch {
return "noop";
}
}
export function touchPlanUpdated() {
setLocalUpdatedAt(Date.now());
}

View File

@ -24,6 +24,13 @@ export function saveTrip(items: unknown[]) {
if (typeof window === "undefined") return;
localStorage.setItem(PRIMARY, JSON.stringify(items));
localStorage.removeItem("nomadflow-trip");
localStorage.setItem("nomadro-plan-updated", String(Date.now()));
try {
const token = localStorage.getItem("nomad-token");
if (token) {
import("./planSync").then((m) => m.schedulePlanSync(token)).catch(() => {});
}
} catch { /* ignore */ }
}
export const TRIP_STORAGE_KEY = PRIMARY;

View File

@ -93,6 +93,17 @@ export interface ProfileStats {
nomad_level: string;
}
export interface SyncedUserPlan {
items: TripItem[];
meta: {
title: string;
startMonth: string;
monthlyBudget: number;
checklist: Record<string, boolean>;
};
updated_at: number;
}
export interface Stats {
countries: number;
avg_cost: number;

View File

@ -1,4 +1,4 @@
"""Direct SFTP sync + native frontend rebuild (when Gitea push fails)."""
"""Direct SFTP sync + native rebuild (when Gitea push fails)."""
from __future__ import annotations
import sys
@ -15,8 +15,10 @@ PASS = "Xiao4669805"
REMOTE = "/opt/nomadweb"
DOMAIN = "nomadweb.nomadro.com"
# Override via env or edit before run
FILES = [
"README.md",
".gitignore",
"frontend/src/app/changelog/page.tsx",
"frontend/src/app/globals.css",
"frontend/src/app/profile/page.tsx",
@ -38,6 +40,13 @@ FILES = [
"frontend/src/lib/compareScore.ts",
"frontend/src/lib/tripActions.ts",
"frontend/src/lib/matcherPrefs.ts",
"frontend/src/lib/tripStorage.ts",
"frontend/src/lib/planSync.ts",
"frontend/src/lib/auth.tsx",
"frontend/src/lib/api.ts",
"backend/app/services/auth.py",
"backend/app/routers/auth.py",
"backend/app/schemas.py",
]
BUILD = f"""
@ -53,8 +62,12 @@ 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 RESTART_API
systemctl restart nomadro-api
echo BUILD_START
cd "$REMOTE/frontend"
npm run build
mkdir -p .next/standalone/.next
rm -rf .next/standalone/public .next/standalone/.next/static
@ -62,7 +75,7 @@ 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
for i in $(seq 1 40); 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"
@ -72,7 +85,7 @@ for i in $(seq 1 30); do
fi
sleep 1
done
curl -skI "https://${{DOMAIN}}/plan" | head -8
curl -skI "https://${{DOMAIN}}/plan" | head -6
curl -sk "https://${{DOMAIN}}/api/v1/health"
echo DIRECT_SYNC=1
[ "$ok" = 1 ]
@ -103,12 +116,15 @@ def main() -> int:
sftp = client.open_sftp()
for rel in FILES:
local = root / rel
if not local.exists():
print(f"SKIP missing {rel}")
continue
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...")
print("Uploaded. Building...")
_, stdout, stderr = client.exec_command(
f"cat > /tmp/nomadro-direct.sh <<'EOF'\n{BUILD}\nEOF\nbash /tmp/nomadro-direct.sh",