From a1b2408ed083fc7f918fe1c8b16277f9d0520790 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 29 Aug 2026 03:10:29 -0500 Subject: [PATCH] Add account-synced trip plans with durable auth store and richer profile hub. Co-authored-by: Cursor --- .gitignore | 3 + README.md | 2 +- backend/app/routers/auth.py | 45 ++++- backend/app/schemas.py | 25 ++- backend/app/services/auth.py | 115 ++++++++++-- frontend/src/app/changelog/page.tsx | 10 + frontend/src/app/globals.css | 72 ++++++++ frontend/src/app/profile/page.tsx | 159 +++++++++++----- .../components/DestinationDetailClient.tsx | 60 +++++- frontend/src/components/MovePlanClient.tsx | 37 ++++ frontend/src/lib/api.ts | 12 +- frontend/src/lib/auth.tsx | 30 ++- frontend/src/lib/planMeta.ts | 7 + frontend/src/lib/planSync.ts | 174 ++++++++++++++++++ frontend/src/lib/tripStorage.ts | 7 + frontend/src/lib/types.ts | 11 ++ scripts/deploy_direct_sync.py | 26 ++- 17 files changed, 710 insertions(+), 85 deletions(-) create mode 100644 frontend/src/lib/planSync.ts diff --git a/.gitignore b/.gitignore index 6ef27e7..f74dd75 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ backend/**/__pycache__/ backend/.env frontend/.env.local +# Auth / plan user store (runtime) +backend/app/data/user_store.json + # PocketBase pocketbase/pb_data/* !pocketbase/pb_data/.gitkeep diff --git a/README.md b/README.md index cf8b8f5..0180c57 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ docker compose up -d | 📝 城市笔记 | 按城本地记录避坑与灵感 | | ⏱️ 专注时钟 | 番茄钟深度工作计时 | | 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 | -| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 | +| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出;登录后云端同步 | | ⚖️ 城市对比台 | `/compare` 可分享多城对比,一键写入计划 | | 📜 更新日志 | `/changelog` 迭代记录 | | 🏧 取现避坑 | ATM / DCC / 换汇建议 | diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5e13137..a06e7f8 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -3,10 +3,11 @@ from fastapi import APIRouter, Header, HTTPException from app.schemas import ( AuthLogin, AuthRegister, AuthResponse, Destination, FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile, + UserPlanPayload, ) from app.services.auth import ( - DEMO_TOKEN, get_favorites, get_user_by_token, - login_user, register_user, toggle_favorite, + get_favorites, get_plan, get_user_by_token, + login_demo, login_user, register_user, save_plan, toggle_favorite, ) from app.services.pocketbase import pb @@ -41,10 +42,10 @@ async def me(authorization: str | None = Header(None)): @router.get("/demo", response_model=AuthResponse) async def demo_login(): """一键体验演示账号""" - user = get_user_by_token(DEMO_TOKEN) - if not user: + result = login_demo() + if not result: raise HTTPException(500, "演示账号不可用") - return AuthResponse(token=DEMO_TOKEN, user=UserProfile(**user)) + return AuthResponse(**result) @router.get("/favorites", response_model=FavoriteResponse) @@ -73,11 +74,14 @@ async def profile_stats(authorization: str | None = Header(None)): if not user: raise HTTPException(401, "未登录") favs = get_favorites(token) + plan = get_plan(token) or {} + cities = len(plan.get("items") or []) levels = [(0, "🌱 新手游民"), (1, "🎒 背包客"), (2, "✈️ 飞行游民"), (3, "🌍 环球游民")] - level = levels[min(len(favs), 3)][1] + explored = max(len(favs), cities) + level = levels[min(explored, 3)][1] return ProfileStats( favorites_count=len(favs), - destinations_explored=len(favs), + destinations_explored=explored, member_since="2026", nomad_level=level, ) @@ -95,6 +99,33 @@ async def add_favorite(body: FavoriteRequest, authorization: str | None = Header return FavoriteResponse(slugs=slugs) +@router.get("/plan", response_model=UserPlanPayload) +async def read_plan(authorization: str | None = Header(None)): + token = _extract_token(authorization) + if not get_user_by_token(token): + raise HTTPException(401, "未登录") + plan = get_plan(token) + if not plan: + raise HTTPException(401, "未登录") + return UserPlanPayload(**plan) + + +@router.put("/plan", response_model=UserPlanPayload) +async def write_plan(body: UserPlanPayload, authorization: str | None = Header(None)): + token = _extract_token(authorization) + if not get_user_by_token(token): + raise HTTPException(401, "未登录") + saved = save_plan( + token, + [item.model_dump() for item in body.items], + body.meta.model_dump(), + body.updated_at or None, + ) + if not saved: + raise HTTPException(401, "未登录") + return UserPlanPayload(**saved) + + def _extract_token(authorization: str | None) -> str: if not authorization: return "" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5774117..6b1e412 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -156,9 +156,32 @@ class ProfileStats(BaseModel): nomad_level: str +class TripItemModel(BaseModel): + slug: str + name: str + country: str + emoji: str + cost: float + months: int = 1 + note: str = "" + + +class PlanMetaModel(BaseModel): + title: str = "我的旅居计划" + startMonth: str = "" + monthlyBudget: float = 0 + checklist: dict[str, bool] = Field(default_factory=dict) + + +class UserPlanPayload(BaseModel): + items: list[TripItemModel] = Field(default_factory=list) + meta: PlanMetaModel = Field(default_factory=PlanMetaModel) + updated_at: int = 0 + + class SearchResult(BaseModel): type: str # destination | blog | visa | faq title: str subtitle: str emoji: str - url: str + url: str \ No newline at end of file diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 072ca64..f3b581c 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -1,20 +1,73 @@ -"""Simple auth service with in-memory fallback when PocketBase users unavailable.""" +"""Auth + favorites + synced user plans. File-backed so demo restarts keep data.""" +from __future__ import annotations + import hashlib +import json import secrets +import time +from pathlib import Path from typing import Any -from app.data import mock_data +STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "user_store.json" -# In-memory store for demo mode _users: dict[str, dict] = {} _sessions: dict[str, str] = {} # token -> user_id _favorites: dict[str, list[str]] = {} # user_id -> [slugs] +_plans: dict[str, dict] = {} # user_id -> { items, meta, updated_at } def _hash_password(password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() +def _persist() -> None: + STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = { + "users": _users, + "sessions": _sessions, + "favorites": _favorites, + "plans": _plans, + } + STORE_PATH.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + +def _restore() -> bool: + if not STORE_PATH.exists(): + return False + try: + data = json.loads(STORE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + global _users, _sessions, _favorites, _plans + _users = data.get("users") or {} + _sessions = data.get("sessions") or {} + _favorites = data.get("favorites") or {} + _plans = data.get("plans") or {} + return True + + +def _user_profile(user: dict) -> dict: + return { + "id": user["id"], + "email": user["email"], + "name": user["name"], + "avatar": user.get("avatar", "🧑‍💻"), + } + + +def _empty_plan() -> dict[str, Any]: + return { + "items": [], + "meta": { + "title": "我的旅居计划", + "startMonth": "", + "monthlyBudget": 0, + "checklist": {}, + }, + "updated_at": 0, + } + + def register_user(email: str, password: str, name: str) -> dict[str, Any] | None: if email in _users: return None @@ -27,8 +80,10 @@ def register_user(email: str, password: str, name: str) -> dict[str, Any] | None "avatar": "🧑‍💻", } _favorites[uid] = [] + _plans[uid] = _empty_plan() token = secrets.token_urlsafe(32) _sessions[token] = uid + _persist() return {"token": token, "user": _user_profile(_users[email])} @@ -38,6 +93,19 @@ def login_user(email: str, password: str) -> dict[str, Any] | None: return None token = secrets.token_urlsafe(32) _sessions[token] = user["id"] + _persist() + return {"token": token, "user": _user_profile(user)} + + +def login_demo() -> dict[str, Any] | None: + email = "demo@nomadro.com" + if email not in _users: + created = register_user(email, "demo123", "演示用户") + return created + user = _users[email] + token = secrets.token_urlsafe(32) + _sessions[token] = user["id"] + _persist() return {"token": token, "user": _user_profile(user)} @@ -55,7 +123,7 @@ def get_favorites(token: str) -> list[str]: uid = _sessions.get(token) if not uid: return [] - return _favorites.get(uid, []) + return list(_favorites.get(uid, [])) def toggle_favorite(token: str, slug: str) -> list[str]: @@ -67,17 +135,38 @@ def toggle_favorite(token: str, slug: str) -> list[str]: favs.remove(slug) else: favs.append(slug) - return favs + _persist() + return list(favs) -def _user_profile(user: dict) -> dict: +def get_plan(token: str) -> dict[str, Any] | None: + uid = _sessions.get(token) + if not uid: + return None + plan = _plans.get(uid) or _empty_plan() return { - "id": user["id"], - "email": user["email"], - "name": user["name"], - "avatar": user.get("avatar", "🧑‍💻"), + "items": plan.get("items") or [], + "meta": plan.get("meta") or _empty_plan()["meta"], + "updated_at": int(plan.get("updated_at") or 0), } -# Demo account -_demo = register_user("demo@nomadro.com", "demo123", "演示用户") -DEMO_TOKEN = _demo["token"] if _demo else "" + +def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None: + uid = _sessions.get(token) + if not uid: + return None + ts = int(updated_at or time.time() * 1000) + _plans[uid] = { + "items": items, + "meta": meta, + "updated_at": ts, + } + _persist() + return get_plan(token) + + +# Boot: restore disk store or seed demo +if not _restore(): + login_demo() +elif "demo@nomadro.com" not in _users: + login_demo() diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index 42c39a6..d935d4b 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,16 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-08-29", + tag: "账号同步", + items: [ + "登录后旅居计划(城市+预算+清单)云端同步,本地与账号自动合并", + "账号与收藏/计划落盘持久化,服务重启不丢演示数据", + "个人中心升级为计划仪表盘:就绪度、预算、收藏写入计划、行程对比", + "目的地详情:停留月数、签证提醒、预算对照、同区对比", + ], + }, { date: "2026-08-29", tag: "决策闭环", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a32deff..a4412ec 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -9095,3 +9095,75 @@ img { max-width: 100%; display: block; } .compare-modal-actions .btn, .compare-page-actions .btn { flex: 1; } } + +/* ===== Plan cloud sync + profile hub ===== */ +.plan-sync-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0 0 16px; +} +.plan-sync-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + font-size: 0.8rem; + border: var(--border-glass); + color: var(--text-secondary); + text-decoration: none; +} +.plan-sync-pill.synced { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); } +.plan-sync-pill.syncing { color: var(--accent-3, #FFE66D); } +.plan-sync-pill.offline { color: var(--accent-1); } +.profile-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.profile-section-head h2 { margin: 0; } +.profile-plan-meta { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--text-secondary) 18%, transparent); +} +.profile-trip-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.dest-months-pick { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--text-secondary); + padding: 8px 10px; + border-radius: var(--radius-md); + border: var(--border-glass); +} +.dest-months-pick input { + width: 48px; + padding: 4px 6px; + border-radius: 6px; + border: var(--border-glass); + background: transparent; + color: var(--text-primary); +} +.dest-plan-hints { + margin: 12px 0 4px; + display: flex; + flex-direction: column; + gap: 6px; +} diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 1bd66ed..300246a 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -9,6 +9,10 @@ import type { Destination, ProfileStats, TripItem } from "@/lib/types"; import SiteShell from "@/components/SiteShell"; import FavoriteButton from "@/components/FavoriteButton"; import { loadTrip } from "@/lib/tripStorage"; +import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta"; +import { PLAN_SYNC_EVENT } from "@/lib/planSync"; +import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; +import { useToast } from "@/lib/toast"; interface Badge { emoji: string; @@ -17,26 +21,33 @@ interface Badge { earned: boolean; } -function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number): Badge[] { +function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number, readiness: number): Badge[] { const tripMonths = trip.reduce((s, t) => s + t.months, 0); return [ { emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true }, { emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 }, { emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 }, { emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 }, + { emoji: "✅", label: "出发就绪", desc: "就绪清单 ≥ 50%", earned: readiness >= 50 }, { emoji: "🌍", label: "环球游民", desc: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 }, - { emoji: "👑", label: "资深游民", desc: "2024 年前加入", earned: parseInt(stats?.member_since ?? "2026") <= 2024 }, ]; } export default function ProfilePage() { - const { user, token, favorites, logout } = useAuth(); + const { user, token, favorites, logout, planSyncStatus } = useAuth(); const router = useRouter(); + const { toast } = useToast(); const [stats, setStats] = useState(null); const [favoriteDests, setFavoriteDests] = useState([]); const [trip, setTrip] = useState([]); + const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(true); + const refreshPlan = () => { + setTrip(loadTrip()); + setMeta(loadPlanMeta()); + }; + useEffect(() => { if (!token) { router.push("/login"); @@ -51,12 +62,36 @@ export default function ProfilePage() { setFavoriteDests(f); }).catch(() => {}).finally(() => setLoading(false)); - setTrip(loadTrip()); + refreshPlan(); + window.addEventListener(PLAN_SYNC_EVENT, refreshPlan); + return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan); }, [token, favorites, router]); - const badges = useMemo(() => getBadges(stats, trip, favoriteDests.length), [stats, trip, favoriteDests.length]); + const totalMonths = trip.reduce((s, t) => s + t.months, 0); + const totalCost = trip.reduce((s, t) => s + t.cost * t.months, 0); + const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0; + const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length; + const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100); + const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget; + + const badges = useMemo( + () => getBadges(stats, trip, favoriteDests.length, readiness), + [stats, trip, favoriteDests.length, readiness] + ); const earnedCount = badges.filter((b) => b.earned).length; + const favsNotInTrip = favoriteDests.filter((d) => !trip.some((t) => t.slug === d.slug)); + + const addFavsToPlan = () => { + if (favsNotInTrip.length === 0) { + toast("收藏城市都已在计划中", "info"); + return; + } + const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1); + refreshPlan(); + toast(`已将 ${added} 座收藏城市写入计划`); + }; + if (!user) return null; return ( @@ -68,6 +103,12 @@ export default function ProfilePage() {

{user.name}

{user.email}

{stats && {stats.nomad_level}} + + {planSyncStatus === "syncing" && "计划同步中…"} + {planSyncStatus === "synced" && "✓ 计划已云端同步"} + {planSyncStatus === "offline" && "计划仅本机"} + {planSyncStatus === "idle" && "账号已登录"} + + )} + {loading ? (

加载中...

) : favoriteDests.length === 0 ? ( @@ -138,44 +240,15 @@ export default function ProfilePage() { )} -
-

🗓️ 我的行程

- {trip.length === 0 ? ( -
- 🗺️ -

还没有规划行程

- 去规划 → -
- ) : ( -
- {trip.map((t, i) => ( -
- {i + 1} - {t.emoji} -
- {t.name}, {t.country} - {t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()} -
-
- ))} -
- 总计 {trip.reduce((s, t) => s + t.months, 0)} 个月 · - ¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()} -
- 打开旅居计划中心 → -
- )} -
-

🚀 快捷入口

🌍 浏览目的地 🗓️ 旅居计划 + ⚖️ 城市对比 📋 签证指南 - 💻 联合办公 📝 阅读博客 - 📊 就绪度测评 + 🛠️ 工具箱
diff --git a/frontend/src/components/DestinationDetailClient.tsx b/frontend/src/components/DestinationDetailClient.tsx index 0f9d26f..2332e14 100644 --- a/frontend/src/components/DestinationDetailClient.tsx +++ b/frontend/src/components/DestinationDetailClient.tsx @@ -1,9 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useToast } from "@/lib/toast"; -import { loadTrip, saveTrip } from "@/lib/tripStorage"; +import { loadTrip } from "@/lib/tripStorage"; +import { loadPlanMeta, stayHint } from "@/lib/planMeta"; +import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; +import { compareUrl } from "@/lib/compareScore"; import type { Destination, TripItem } from "@/lib/types"; const TZ_LABELS: Record = { @@ -30,7 +33,11 @@ function getScores(d: Destination) { export default function DestinationDetailClient({ dest, allDestinations }: Props) { const { toast } = useToast(); const [added, setAdded] = useState(false); + const [months, setMonths] = useState(1); const scores = getScores(dest); + const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]); + const budget = useMemo(() => loadPlanMeta().monthlyBudget, []); + const overBudget = budget > 0 && dest.cost > budget; const related = allDestinations .filter((d) => d.region === dest.region && d.slug !== dest.slug) @@ -40,12 +47,17 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props const trip = loadTrip(); if (trip.some((t) => t.slug === dest.slug)) { toast("该城市已在行程中", "info"); + setAdded(true); return; } - trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1, note: "" }); - saveTrip(trip); + const { added: n } = mergeDestinationsIntoTrip([dest], months); setAdded(true); - toast(`${dest.emoji} ${dest.name} 已加入旅居计划`); + toast( + n > 0 + ? `${dest.emoji} ${dest.name} 已加入旅居计划(${months} 个月)` + : "该城市已在计划中", + n > 0 ? "success" : "info" + ); }; const share = async () => { @@ -69,13 +81,47 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props {added ? ( ✓ 已在计划中 · 打开 → ) : ( - + <> + + + )} + {related.length > 0 && ( + d.slug)])} + className="btn btn-ghost" + > + ⚖️ 对比同区 + + )} 🗓️ 计划中心 - 🧮 算费用 + 📋 签证 + {(visa || overBudget) && ( +
+ {visa && ( +

🛂 {visa.text}

+ )} + {overBudget && ( +

+ 💰 月生活费 ¥{dest.cost.toLocaleString()} 高于你的计划月预算 ¥{budget.toLocaleString()} +

+ )} +
+ )} +

📊 城市画像

diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx index 98bfa61..b5fde31 100644 --- a/frontend/src/components/MovePlanClient.tsx +++ b/frontend/src/components/MovePlanClient.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useToast } from "@/lib/toast"; +import { useAuth } from "@/lib/auth"; import { loadTrip, saveTrip } from "@/lib/tripStorage"; import { loadPlanMeta, @@ -12,6 +13,7 @@ import { MOVE_CHECKLIST, type PlanMeta, } from "@/lib/planMeta"; +import { PLAN_SYNC_EVENT, pushPlanNow } from "@/lib/planSync"; import type { Destination, TripItem } from "@/lib/types"; interface Props { @@ -31,6 +33,7 @@ function scoreDest(d: Destination) { export default function MovePlanClient({ destinations }: Props) { const { toast } = useToast(); + const { token, user, planSyncStatus } = useAuth(); const [trip, setTrip] = useState([]); const [meta, setMeta] = useState(() => ({ title: "我的旅居计划", @@ -62,6 +65,15 @@ export default function MovePlanClient({ destinations }: Props) { setReady(true); }, [toast]); + useEffect(() => { + const refresh = () => { + setTrip(loadTrip()); + setMeta(loadPlanMeta()); + }; + window.addEventListener(PLAN_SYNC_EVENT, refresh); + return () => window.removeEventListener(PLAN_SYNC_EVENT, refresh); + }, []); + const persistTrip = (items: TripItem[]) => { setTrip(items); saveTrip(items); @@ -216,6 +228,31 @@ export default function MovePlanClient({ destinations }: Props) { aria-label="计划标题" />

把多城路线、预算、签证提醒和出发清单放在一页——真正能拿去执行的旅居计划。

+
+ {user ? ( + <> + + {planSyncStatus === "syncing" && "同步中…"} + {planSyncStatus === "synced" && "✓ 已登录 · 计划云端同步"} + {planSyncStatus === "offline" && "离线 · 仅保存在本机"} + {planSyncStatus === "idle" && "已登录"} + + + + ) : ( + 登录后跨设备同步计划 → + )} +