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:
parent
fd87f765c8
commit
a1b2408ed0
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -136,7 +136,7 @@ docker compose up -d
|
|||||||
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
|
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
|
||||||
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
|
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
|
||||||
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
||||||
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 |
|
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出;登录后云端同步 |
|
||||||
| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 |
|
| ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 |
|
||||||
| 📜 更新日志 | `/changelog` 迭代记录 |
|
| 📜 更新日志 | `/changelog` 迭代记录 |
|
||||||
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|||||||
@ -156,6 +156,29 @@ 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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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: "决策闭环",
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
<div className="profile-section-head">
|
||||||
<h2>❤️ 我的收藏</h2>
|
<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>
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<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-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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 由出发月 + 各站月数推算大致区间文案 */
|
/** 由出发月 + 各站月数推算大致区间文案 */
|
||||||
|
|||||||
174
frontend/src/lib/planSync.ts
Normal file
174
frontend/src/lib/planSync.ts
Normal 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());
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user