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 backend/.env
frontend/.env.local frontend/.env.local
# Auth / plan user store (runtime)
backend/app/data/user_store.json
# PocketBase # PocketBase
pocketbase/pb_data/* pocketbase/pb_data/*
!pocketbase/pb_data/.gitkeep !pocketbase/pb_data/.gitkeep

View File

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

View File

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

View File

@ -156,9 +156,32 @@ class ProfileStats(BaseModel):
nomad_level: str 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): class SearchResult(BaseModel):
type: str # destination | blog | visa | faq type: str # destination | blog | visa | faq
title: str title: str
subtitle: str subtitle: str
emoji: 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 hashlib
import json
import secrets import secrets
import time
from pathlib import Path
from typing import Any 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] = {} _users: dict[str, dict] = {}
_sessions: dict[str, str] = {} # token -> user_id _sessions: dict[str, str] = {} # token -> user_id
_favorites: dict[str, list[str]] = {} # user_id -> [slugs] _favorites: dict[str, list[str]] = {} # user_id -> [slugs]
_plans: dict[str, dict] = {} # user_id -> { items, meta, updated_at }
def _hash_password(password: str) -> str: def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest() 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: def register_user(email: str, password: str, name: str) -> dict[str, Any] | None:
if email in _users: if email in _users:
return None return None
@ -27,8 +80,10 @@ def register_user(email: str, password: str, name: str) -> dict[str, Any] | None
"avatar": "🧑‍💻", "avatar": "🧑‍💻",
} }
_favorites[uid] = [] _favorites[uid] = []
_plans[uid] = _empty_plan()
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
_sessions[token] = uid _sessions[token] = uid
_persist()
return {"token": token, "user": _user_profile(_users[email])} 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 return None
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
_sessions[token] = user["id"] _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)} return {"token": token, "user": _user_profile(user)}
@ -55,7 +123,7 @@ def get_favorites(token: str) -> list[str]:
uid = _sessions.get(token) uid = _sessions.get(token)
if not uid: if not uid:
return [] return []
return _favorites.get(uid, []) return list(_favorites.get(uid, []))
def toggle_favorite(token: str, slug: str) -> list[str]: 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) favs.remove(slug)
else: else:
favs.append(slug) 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 { return {
"id": user["id"], "items": plan.get("items") or [],
"email": user["email"], "meta": plan.get("meta") or _empty_plan()["meta"],
"name": user["name"], "updated_at": int(plan.get("updated_at") or 0),
"avatar": user.get("avatar", "🧑‍💻"),
} }
# Demo account
_demo = register_user("demo@nomadro.com", "demo123", "演示用户") def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None:
DEMO_TOKEN = _demo["token"] if _demo else "" 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 = [ const LOGS = [
{
date: "2026-08-29",
tag: "账号同步",
items: [
"登录后旅居计划(城市+预算+清单)云端同步,本地与账号自动合并",
"账号与收藏/计划落盘持久化,服务重启不丢演示数据",
"个人中心升级为计划仪表盘:就绪度、预算、收藏写入计划、行程对比",
"目的地详情:停留月数、签证提醒、预算对照、同区对比",
],
},
{ {
date: "2026-08-29", date: "2026-08-29",
tag: "决策闭环", tag: "决策闭环",

View File

@ -9095,3 +9095,75 @@ img { max-width: 100%; display: block; }
.compare-modal-actions .btn, .compare-modal-actions .btn,
.compare-page-actions .btn { flex: 1; } .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 SiteShell from "@/components/SiteShell";
import FavoriteButton from "@/components/FavoriteButton"; import FavoriteButton from "@/components/FavoriteButton";
import { loadTrip } from "@/lib/tripStorage"; 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 { interface Badge {
emoji: string; emoji: string;
@ -17,26 +21,33 @@ interface Badge {
earned: boolean; 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); const tripMonths = trip.reduce((s, t) => s + t.months, 0);
return [ return [
{ emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true }, { emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true },
{ emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 }, { emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 },
{ emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 }, { emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 },
{ emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 }, { 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: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 },
{ emoji: "👑", label: "资深游民", desc: "2024 年前加入", earned: parseInt(stats?.member_since ?? "2026") <= 2024 },
]; ];
} }
export default function ProfilePage() { export default function ProfilePage() {
const { user, token, favorites, logout } = useAuth(); const { user, token, favorites, logout, planSyncStatus } = useAuth();
const router = useRouter(); const router = useRouter();
const { toast } = useToast();
const [stats, setStats] = useState<ProfileStats | null>(null); const [stats, setStats] = useState<ProfileStats | null>(null);
const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]); const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]);
const [trip, setTrip] = useState<TripItem[]>([]); const [trip, setTrip] = useState<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const refreshPlan = () => {
setTrip(loadTrip<TripItem>());
setMeta(loadPlanMeta());
};
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
router.push("/login"); router.push("/login");
@ -51,12 +62,36 @@ export default function ProfilePage() {
setFavoriteDests(f); setFavoriteDests(f);
}).catch(() => {}).finally(() => setLoading(false)); }).catch(() => {}).finally(() => setLoading(false));
setTrip(loadTrip<TripItem>()); refreshPlan();
window.addEventListener(PLAN_SYNC_EVENT, refreshPlan);
return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan);
}, [token, favorites, router]); }, [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 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; if (!user) return null;
return ( return (
@ -68,6 +103,12 @@ export default function ProfilePage() {
<h1>{user.name}</h1> <h1>{user.name}</h1>
<p>{user.email}</p> <p>{user.email}</p>
{stats && <span className="profile-level">{stats.nomad_level}</span>} {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> </div>
<button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}> <button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}>
退出登录 退出登录
@ -82,14 +123,14 @@ export default function ProfilePage() {
<span>收藏目的地</span> <span>收藏目的地</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">🗺️</span> <span className="stat-emoji">🗓️</span>
<strong>{stats.destinations_explored}</strong> <strong>{trip.length}</strong>
<span>探索城市</span> <span>计划城市</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">📅</span> <span className="stat-emoji">✅</span>
<strong>{stats.member_since}</strong> <strong>{readiness}%</strong>
<span>加入年份</span> <span>出发就绪</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">🏅</span> <span className="stat-emoji">🏅</span>
@ -99,6 +140,60 @@ export default function ProfilePage() {
</div> </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"> <section className="profile-badges">
<h2>🏅 游民成就</h2> <h2>🏅 游民成就</h2>
<div className="badge-grid"> <div className="badge-grid">
@ -113,7 +208,14 @@ export default function ProfilePage() {
</section> </section>
<section className="profile-favorites"> <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 ? ( {loading ? (
<p className="profile-loading">加载中...</p> <p className="profile-loading">加载中...</p>
) : favoriteDests.length === 0 ? ( ) : favoriteDests.length === 0 ? (
@ -138,44 +240,15 @@ export default function ProfilePage() {
)} )}
</section> </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"> <section className="profile-quick">
<h2>🚀 快捷入口</h2> <h2>🚀 快捷入口</h2>
<div className="profile-quick-grid"> <div className="profile-quick-grid">
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link> <Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
<Link href="/plan" 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="/#visa" className="quick-card">📋 签证指南</Link>
<Link href="/#coworking" className="quick-card">💻 联合办公</Link>
<Link href="/#blog" 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> </div>
</section> </section>
</div> </div>

View File

@ -1,9 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useToast } from "@/lib/toast"; 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"; import type { Destination, TripItem } from "@/lib/types";
const TZ_LABELS: Record<string, string> = { const TZ_LABELS: Record<string, string> = {
@ -30,7 +33,11 @@ function getScores(d: Destination) {
export default function DestinationDetailClient({ dest, allDestinations }: Props) { export default function DestinationDetailClient({ dest, allDestinations }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const [added, setAdded] = useState(false); const [added, setAdded] = useState(false);
const [months, setMonths] = useState(1);
const scores = getScores(dest); 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 const related = allDestinations
.filter((d) => d.region === dest.region && d.slug !== dest.slug) .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>(); const trip = loadTrip<TripItem>();
if (trip.some((t) => t.slug === dest.slug)) { if (trip.some((t) => t.slug === dest.slug)) {
toast("该城市已在行程中", "info"); toast("该城市已在行程中", "info");
setAdded(true);
return; return;
} }
trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1, note: "" }); const { added: n } = mergeDestinationsIntoTrip([dest], months);
saveTrip(trip);
setAdded(true); setAdded(true);
toast(`${dest.emoji} ${dest.name} 已加入旅居计划`); toast(
n > 0
? `${dest.emoji} ${dest.name} 已加入旅居计划(${months} 个月)`
: "该城市已在计划中",
n > 0 ? "success" : "info"
);
}; };
const share = async () => { const share = async () => {
@ -69,13 +81,47 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{added ? ( {added ? (
<Link href="/plan" className="btn btn-primary">✓ 已在计划中 · 打开 →</Link> <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> <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="/plan" className="btn btn-ghost">🗓️ 计划中心</Link>
<Link href={`/#calculator`} className="btn btn-ghost">🧮 算费用</Link> <Link href="/#visa" className="btn btn-ghost">📋 签证</Link>
</div> </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-detail-extras">
<div className="dest-radar-card"> <div className="dest-radar-card">
<h3>📊 城市画像</h3> <h3>📊 城市画像</h3>

View File

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useAuth } from "@/lib/auth";
import { loadTrip, saveTrip } from "@/lib/tripStorage"; import { loadTrip, saveTrip } from "@/lib/tripStorage";
import { import {
loadPlanMeta, loadPlanMeta,
@ -12,6 +13,7 @@ import {
MOVE_CHECKLIST, MOVE_CHECKLIST,
type PlanMeta, type PlanMeta,
} from "@/lib/planMeta"; } from "@/lib/planMeta";
import { PLAN_SYNC_EVENT, pushPlanNow } from "@/lib/planSync";
import type { Destination, TripItem } from "@/lib/types"; import type { Destination, TripItem } from "@/lib/types";
interface Props { interface Props {
@ -31,6 +33,7 @@ function scoreDest(d: Destination) {
export default function MovePlanClient({ destinations }: Props) { export default function MovePlanClient({ destinations }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const { token, user, planSyncStatus } = useAuth();
const [trip, setTrip] = useState<TripItem[]>([]); const [trip, setTrip] = useState<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta>(() => ({ const [meta, setMeta] = useState<PlanMeta>(() => ({
title: "我的旅居计划", title: "我的旅居计划",
@ -62,6 +65,15 @@ export default function MovePlanClient({ destinations }: Props) {
setReady(true); setReady(true);
}, [toast]); }, [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[]) => { const persistTrip = (items: TripItem[]) => {
setTrip(items); setTrip(items);
saveTrip(items); saveTrip(items);
@ -216,6 +228,31 @@ export default function MovePlanClient({ destinations }: Props) {
aria-label="计划标题" aria-label="计划标题"
/> />
<p>把多城路线、预算、签证提醒和出发清单放在一页——真正能拿去执行的旅居计划。</p> <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"> <div className="plan-meta-row">
<label className="plan-meta-field"> <label className="plan-meta-field">
<span>预计出发月</span> <span>预计出发月</span>

View File

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

View File

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

View File

@ -99,6 +99,13 @@ export function savePlanMeta(meta: PlanMeta) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
localStorage.setItem(PRIMARY, JSON.stringify(meta)); localStorage.setItem(PRIMARY, JSON.stringify(meta));
localStorage.removeItem("nomadflow-plan-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; if (typeof window === "undefined") return;
localStorage.setItem(PRIMARY, JSON.stringify(items)); localStorage.setItem(PRIMARY, JSON.stringify(items));
localStorage.removeItem("nomadflow-trip"); 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; export const TRIP_STORAGE_KEY = PRIMARY;

View File

@ -93,6 +93,17 @@ export interface ProfileStats {
nomad_level: string; 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 { export interface Stats {
countries: number; countries: number;
avg_cost: 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 from __future__ import annotations
import sys import sys
@ -15,8 +15,10 @@ PASS = "Xiao4669805"
REMOTE = "/opt/nomadweb" REMOTE = "/opt/nomadweb"
DOMAIN = "nomadweb.nomadro.com" DOMAIN = "nomadweb.nomadro.com"
# Override via env or edit before run
FILES = [ FILES = [
"README.md", "README.md",
".gitignore",
"frontend/src/app/changelog/page.tsx", "frontend/src/app/changelog/page.tsx",
"frontend/src/app/globals.css", "frontend/src/app/globals.css",
"frontend/src/app/profile/page.tsx", "frontend/src/app/profile/page.tsx",
@ -38,6 +40,13 @@ FILES = [
"frontend/src/lib/compareScore.ts", "frontend/src/lib/compareScore.ts",
"frontend/src/lib/tripActions.ts", "frontend/src/lib/tripActions.ts",
"frontend/src/lib/matcherPrefs.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""" BUILD = f"""
@ -53,8 +62,12 @@ export NEXT_TELEMETRY_DISABLED=1
export NEXT_PUBLIC_API_URL=https://${{DOMAIN}}/api/v1 export NEXT_PUBLIC_API_URL=https://${{DOMAIN}}/api/v1
export NEXT_PUBLIC_SITE_URL=https://${{DOMAIN}} export NEXT_PUBLIC_SITE_URL=https://${{DOMAIN}}
export NODE_ENV=production export NODE_ENV=production
cd "$REMOTE/frontend"
echo RESTART_API
systemctl restart nomadro-api
echo BUILD_START echo BUILD_START
cd "$REMOTE/frontend"
npm run build npm run build
mkdir -p .next/standalone/.next mkdir -p .next/standalone/.next
rm -rf .next/standalone/public .next/standalone/.next/static 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 cp -a .next/static .next/standalone/.next/static
systemctl restart nomadro-web systemctl restart nomadro-web
ok=0 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) 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) api=$(curl -s http://127.0.0.1:8055/api/v1/health || true)
echo "try=$i web=$web api=$api" echo "try=$i web=$web api=$api"
@ -72,7 +85,7 @@ for i in $(seq 1 30); do
fi fi
sleep 1 sleep 1
done done
curl -skI "https://${{DOMAIN}}/plan" | head -8 curl -skI "https://${{DOMAIN}}/plan" | head -6
curl -sk "https://${{DOMAIN}}/api/v1/health" curl -sk "https://${{DOMAIN}}/api/v1/health"
echo DIRECT_SYNC=1 echo DIRECT_SYNC=1
[ "$ok" = 1 ] [ "$ok" = 1 ]
@ -103,12 +116,15 @@ def main() -> int:
sftp = client.open_sftp() sftp = client.open_sftp()
for rel in FILES: for rel in FILES:
local = root / rel local = root / rel
if not local.exists():
print(f"SKIP missing {rel}")
continue
remote = f"{REMOTE}/{rel}" remote = f"{REMOTE}/{rel}"
print(f"UPLOAD {rel}") print(f"UPLOAD {rel}")
ensure_dir(sftp, str(Path(remote).parent).replace("\\", "/")) ensure_dir(sftp, str(Path(remote).parent).replace("\\", "/"))
sftp.put(str(local), remote) sftp.put(str(local), remote)
sftp.close() sftp.close()
print("Uploaded. Building frontend...") print("Uploaded. Building...")
_, stdout, stderr = client.exec_command( _, stdout, stderr = client.exec_command(
f"cat > /tmp/nomadro-direct.sh <<'EOF'\n{BUILD}\nEOF\nbash /tmp/nomadro-direct.sh", f"cat > /tmp/nomadro-direct.sh <<'EOF'\n{BUILD}\nEOF\nbash /tmp/nomadro-direct.sh",