From 9b3f210bd2386338e07fea0e3f5d47c22d48c257 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 30 Aug 2026 11:54:10 -0500 Subject: [PATCH] Add city detail depth, enriched routes, and meetup auth flows. Port NomadCNA city_details/content/routes seeding into PocketBase and surface full destination pages with related meetups and discussions. Co-authored-by: Cursor --- backend/app/data/city_details_data.py | 237 ++++++++++++++++++ backend/app/data/community_data.py | 20 +- backend/app/main.py | 3 +- backend/app/routers/api.py | 31 ++- backend/app/routers/meetup_auth.py | 86 +++++++ backend/app/services/auth.py | 30 +++ backend/app/services/city_service.py | 138 ++++++++++ backend/scripts/init_pb_collections.py | 77 +++++- frontend/src/app/globals.css | 70 ++++++ .../components/DestinationDetailClient.tsx | 151 ++++++++++- frontend/src/lib/api.ts | 61 +++++ scripts/deploy_backend_hotfix.py | 5 + 12 files changed, 895 insertions(+), 14 deletions(-) create mode 100644 backend/app/data/city_details_data.py create mode 100644 backend/app/routers/meetup_auth.py create mode 100644 backend/app/services/city_service.py diff --git a/backend/app/data/city_details_data.py b/backend/app/data/city_details_data.py new file mode 100644 index 0000000..6ddf15b --- /dev/null +++ b/backend/app/data/city_details_data.py @@ -0,0 +1,237 @@ +"""City detail depth for destinations — NomadCNA city_details adapted to nomadro slugs.""" +from __future__ import annotations + +from typing import Any + +from app.data.mock_data import DESTINATIONS + + +def _city_detail(dest: dict[str, Any], index: int) -> dict[str, Any]: + slug = dest["slug"] + name = dest["name"] + cost = int(dest.get("cost") or 5000) + speed = int(dest.get("speed") or 50) + temp = int(dest.get("temperature") or 22) + rating = float(dest.get("rating") or 8.5) + nomads_raw = str(dest.get("nomads_count") or "1000") + nomads = int("".join(c for c in nomads_raw if c.isdigit()) or "1000") + tags = dest.get("highlights") or [] + tag_short = [t.replace("🏄 ", "").replace("🧘 ", "").replace("💰 ", "").replace("🌴 ", "")[:12] for t in tags][:4] + rent = max(1200, round(cost * 0.42)) + food = max(800, round(cost * 0.28)) + cowork = max(200, round(cost * 0.12)) + transport = max(200, round(cost * 0.08)) + leisure = max(300, cost - rent - food - cowork - transport) + hue = int(dest.get("hue") or 170) + + return { + "citySlug": slug, + "guide": { + "summary": f"{name}适合想把远程工作和生活质量同时拉高的人。建议先用 2–4 周短住验证网络、社群和生活节奏,再决定是否长期停留。{dest.get('description', '')[:80]}", + "bestFor": tag_short or ["远程办公", "旅居试住"], + "workSetup": f"建议选择靠近核心生活区的住处,搭配 1 个固定共享办公点和 2 个备用咖啡馆。当前网速参考值为 {speed} Mbps。", + "arrivalChecklist": ["确认 4G/5G 覆盖", "预约第一周住宿", "加入本地社群", "收藏 2 个办公点", "报名一场线下活动"], + "linkedContent": ["digital-nomad-guide", "visa-playbook"], + }, + "pros": { + "pros": [ + f"月均生活成本约 ¥{cost:,},适合控制预算。", + f"社区活跃度参考 {nomads_raw},容易找到同频伙伴。", + f"综合评分 {rating}/10,亮点:{'、'.join(tag_short[:3]) or name}。", + ], + "cons": [ + "热门区域旺季住宿价格波动较大。", + "长期停留前仍需实测具体住处网络。", + "社交圈集中,需要主动参加活动融入。", + ], + }, + "reviews": { + "rating": rating, + "items": [ + {"author": "小林", "role": "前端开发", "text": f"{name}最适合有稳定远程收入的人,白天专注工作,晚上参加小规模聚会。", "score": 5}, + {"author": "Marco", "role": "UI 设计师", "text": "如果你需要高强度商务会面,建议先看交通和航班;如果是深度工作,这里很舒服。", "score": 4}, + {"author": "阿静", "role": "内容创作者", "text": "生活素材很多,但要避免把旅行感当成生产力,最好保持固定作息。", "score": 4}, + ], + }, + "cost": { + "monthlyTotal": cost, + "currency": "CNY", + "breakdown": [ + {"label": "住宿", "amount": rent}, + {"label": "餐饮", "amount": food}, + {"label": "共享办公", "amount": cowork}, + {"label": "交通", "amount": transport}, + {"label": "社交/休闲", "amount": leisure}, + ], + "tip": "预算表与城市卡片成本字段关联,可按个人消费校准。", + }, + "people": { + "nomadsNow": nomads, + "personas": [ + {"name": "远程全职", "percent": 36}, + {"name": "自由职业", "percent": 28}, + {"name": "独立开发者", "percent": 18}, + {"name": "内容创作者", "percent": 18}, + ], + "featuredProfiles": ["小林", "Marco", "阿静"], + }, + "chat": { + "channels": [ + {"name": f"{name}落地互助群", "members": min(999, nomads), "status": "open"}, + {"name": f"{name}共享办公情报", "members": round(nomads * 0.42), "status": "open"}, + {"name": "周末活动约局", "members": round(nomads * 0.28), "status": "members"}, + ], + "latestTopics": ["本周线下咖啡局", "哪里适合视频会议", "短租避坑清单"], + }, + "photos": { + "items": [ + f"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&h={hue}", + "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=1200", + "https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?w=1200", + ] + }, + "weather": { + "temperature": temp, + "humidity": 55 + (index * 3) % 30, + "bestMonths": ["3月", "4月", "10月", "11月"], + "note": f"当前气候估算适合中长期试住,体感温度需结合季节和住宿条件判断。", + }, + "trends": { + "growth": [ + {"month": "1月", "value": max(50, nomads - 160)}, + {"month": "2月", "value": max(70, nomads - 120)}, + {"month": "3月", "value": max(90, nomads - 80)}, + {"month": "4月", "value": max(120, nomads - 40)}, + {"month": "5月", "value": nomads}, + ], + "insight": f"{name}的社群热度正在上升,新增用户主要来自远程全职和自由职业人群。", + }, + "demographics": { + "age": [ + {"label": "22-27", "value": 24}, + {"label": "28-34", "value": 46}, + {"label": "35-44", "value": 22}, + {"label": "45+", "value": 8}, + ], + "work": [ + {"label": "技术/产品", "value": 34}, + {"label": "内容/设计", "value": 26}, + {"label": "运营/市场", "value": 18}, + {"label": "创业/自由职业", "value": 22}, + ], + }, + "relatedContent": ["digital-nomad-guide", "visa-playbook"], + } + + +CITY_DETAILS: list[dict[str, Any]] = [ + _city_detail(d, i) for i, d in enumerate(DESTINATIONS) +] + +CITY_DETAILS_BY_SLUG: dict[str, dict[str, Any]] = { + d["citySlug"]: d for d in CITY_DETAILS +} + + +# Extra nomad routes aligned with destination slugs +EXTRA_ROUTES: list[dict[str, Any]] = [ + { + "slug": "sea-slow-trail", + "title": "东南亚慢旅三角", + "titleEn": "SEA Slow Trail", + "citySlugs": ["chiangmai", "bali"], + "durationDays": 90, + "budget": 18000, + "description": "清迈 → 巴厘岛,低成本 + 高速网络 + 成熟游民社区。", + "descriptionEn": "Chiang Mai to Bali for cost and community.", + "stops": ["清迈 45 天", "巴厘岛 45 天"], + "status": "published", + "emoji": "🌴", + }, + { + "slug": "euro-culture-loop", + "title": "欧洲文化环线", + "titleEn": "Euro Culture Loop", + "citySlugs": ["lisbon", "barcelona"], + "durationDays": 60, + "budget": 35000, + "description": "里斯本 → 巴塞罗那,签证友好、文化丰富。", + "descriptionEn": "Lisbon to Barcelona for visas and culture.", + "stops": ["里斯本 30 天", "巴塞罗那 30 天"], + "status": "published", + "emoji": "🏰", + }, + { + "slug": "latam-budget-run", + "title": "拉美性价比冲刺", + "titleEn": "LATAM Budget Run", + "citySlugs": ["mexico"], + "durationDays": 45, + "budget": 12000, + "description": "墨西哥城深度试住,贴近北美时区、生活成本可控。", + "descriptionEn": "Mexico City deep stay near North America timezones.", + "stops": ["墨西哥城 45 天"], + "status": "published", + "emoji": "🌮", + }, + { + "slug": "asia-metro-sprint", + "title": "亚洲都市效率线", + "titleEn": "Asia Metro Sprint", + "citySlugs": ["tokyo", "chiangmai"], + "durationDays": 40, + "budget": 28000, + "description": "东京高效率冲刺 + 清迈成本回调,适合产品与工程团队。", + "descriptionEn": "Tokyo sprint then Chiang Mai cooldown.", + "stops": ["东京 14 天", "清迈 26 天"], + "status": "published", + "emoji": "🚄", + }, +] + + +CONTENT_ITEMS: list[dict[str, Any]] = [ + { + "slug": "digital-nomad-guide", + "type": "guide", + "title": "数字游民落地手册", + "titleEn": "Digital Nomad Landing Guide", + "subtitle": "签证、网络、住宿、社群一次看懂", + "description": "覆盖东南亚与欧洲热门城市的 30 天落地清单。", + "coverImage": "", + "mediaUrl": "", + "targetUrl": "/digital", + "ctaLabel": "打开手册", + "citySlugs": ["bali", "chiangmai", "lisbon"], + "sortOrder": 1, + "status": "published", + "authorName": "nomadro", + }, + { + "slug": "visa-playbook", + "type": "guide", + "title": "签证选址速查", + "titleEn": "Visa Playbook", + "subtitle": "D7 / Nomad Visa / LTR 对照", + "description": "按收入门槛与审批周期对比主流签证路径。", + "targetUrl": "/#visa", + "ctaLabel": "看签证", + "citySlugs": ["lisbon", "barcelona", "tokyo"], + "sortOrder": 2, + "status": "published", + "authorName": "nomadro", + }, + { + "slug": "bali-cowork-week", + "type": "video", + "title": "巴厘岛一周联合办公实录", + "subtitle": "Canggu 节奏与咖啡馆踩点", + "description": "适合第一次去巴厘岛远程工作的人。", + "targetUrl": "/videos", + "ctaLabel": "看视频", + "citySlugs": ["bali"], + "sortOrder": 3, + "status": "published", + "authorName": "nomadro", + }, +] diff --git a/backend/app/data/community_data.py b/backend/app/data/community_data.py index 7db8d29..37604e3 100644 --- a/backend/app/data/community_data.py +++ b/backend/app/data/community_data.py @@ -238,7 +238,7 @@ NOMAD_ROUTES = [ "duration_days": 90, "budget": 18000, "description": "清迈 → 巴厘岛 → 吉隆坡,低成本 + 高速网络 + 成熟游民社区。", - "stops": ["chiang-mai", "bali", "kuala-lumpur"], + "stops": ["chiangmai", "bali"], "emoji": "🌴", }, { @@ -247,8 +247,8 @@ NOMAD_ROUTES = [ "title": "欧洲文化环线", "duration_days": 60, "budget": 35000, - "description": "里斯本 → 巴塞罗那 → 柏林,签证友好、文化丰富。", - "stops": ["lisbon", "barcelona", "berlin"], + "description": "里斯本 → 巴塞罗那,签证友好、文化丰富。", + "stops": ["lisbon", "barcelona"], "emoji": "🏰", }, { @@ -257,8 +257,18 @@ NOMAD_ROUTES = [ "title": "拉美性价比冲刺", "duration_days": 45, "budget": 12000, - "description": "墨西哥城 → 麦德林,贴近北美时区、生活成本低。", - "stops": ["mexico-city", "medellin"], + "description": "墨西哥城深度试住,贴近北美时区、生活成本低。", + "stops": ["mexico"], "emoji": "🌮", }, + { + "id": "asia-metro", + "slug": "asia-metro-sprint", + "title": "亚洲都市效率线", + "duration_days": 40, + "budget": 28000, + "description": "东京高效率冲刺 + 清迈成本回调。", + "stops": ["tokyo", "chiangmai"], + "emoji": "🚄", + }, ] diff --git a/backend/app/main.py b/backend/app/main.py index 7dee879..7802bd0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import api, auth, community, media, payment, platform, social +from app.routers import api, auth, community, media, meetup_auth, payment, platform, social app = FastAPI( title="nomadro API", @@ -20,6 +20,7 @@ app.add_middleware( app.include_router(api.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1") +app.include_router(meetup_auth.router, prefix="/api/v1") app.include_router(social.router, prefix="/api/v1") app.include_router(community.router, prefix="/api/v1") app.include_router(payment.router, prefix="/api/v1") diff --git a/backend/app/routers/api.py b/backend/app/routers/api.py index 86ab3fb..3f99e36 100644 --- a/backend/app/routers/api.py +++ b/backend/app/routers/api.py @@ -17,6 +17,7 @@ from app.services.meetup_live import ( ) from app.services.auth import get_user_by_token from app.services import community_store, social_store +from app.services import city_service from app.data import digital_content router = APIRouter() @@ -64,6 +65,24 @@ async def get_destination(slug: str): return Destination(**data) +@router.get("/destinations/{slug}/full") +async def get_destination_full(slug: str): + """City depth: guide / cost / reviews / related meetups & content.""" + data = await city_service.get_destination_full(slug) + if not data: + raise HTTPException(404, "目的地不存在") + return data + + +@router.get("/cities/{slug}") +async def city_detail_compat(slug: str): + """NomadCNA-compatible city detail payload.""" + data = await city_service.get_destination_full(slug) + if not data: + raise HTTPException(404, "城市不存在") + return data + + @router.post("/destinations/compare", response_model=list[Destination]) async def compare_destinations(body: CompareRequest): all_dests = await pb.get_destinations() @@ -271,7 +290,14 @@ async def get_discussion(discussion_id: str): @router.get("/routes", response_model=list[NomadRoute]) async def list_routes(): - return [NomadRoute(**r) for r in community_data.NOMAD_ROUTES] + dests = await pb.get_destinations() + return [NomadRoute(**{k: v for k, v in r.items() if k != "cities"}) for r in city_service.list_routes_enriched(dests)] + + +@router.get("/routes/full") +async def list_routes_full(): + dests = await pb.get_destinations() + return {"ok": True, "items": city_service.list_routes_enriched(dests)} @router.post("/recommendations/next-stop", response_model=NextStopResponse) @@ -286,9 +312,10 @@ async def next_stop_recommendations(body: NextStopRequest): priority=body.priority, limit=body.limit, ) + routes = city_service.list_routes_enriched(dests) return NextStopResponse( items=[RecommendedDestination(**d) for d in recommended], - routes=[NomadRoute(**r) for r in community_data.NOMAD_ROUTES], + routes=[NomadRoute(**{k: v for k, v in r.items() if k != "cities"}) for r in routes], ) diff --git a/backend/app/routers/meetup_auth.py b/backend/app/routers/meetup_auth.py new file mode 100644 index 0000000..65b661f --- /dev/null +++ b/backend/app/routers/meetup_auth.py @@ -0,0 +1,86 @@ +"""Meetup auth helpers — NomadCNA check-user / ensure-user / complete-order.""" +from __future__ import annotations + +from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel, EmailStr, Field + +from app.services.auth import ensure_user_by_email, find_user_public_by_email, get_user_by_token +from app.services import social_store + +router = APIRouter(prefix="/meetup", tags=["meetup-auth"]) + + +class CheckUserPayload(BaseModel): + email: EmailStr + + +class EnsureUserPayload(BaseModel): + email: EmailStr + name: str = Field(default="", max_length=120) + + +class CompleteOrderPayload(BaseModel): + order_id: str = Field(min_length=4, max_length=160) + + +@router.post("/check-user") +async def check_user(payload: CheckUserPayload): + user = find_user_public_by_email(str(payload.email).lower()) + vip = bool(user and social_store.is_vip(user["id"])) + return { + "ok": True, + "exists": bool(user), + "vip": vip, + "user_id": user["id"] if user else None, + } + + +@router.post("/ensure-user") +async def ensure_user(payload: EnsureUserPayload): + try: + result = ensure_user_by_email(str(payload.email).lower(), payload.name) + except Exception as exc: + raise HTTPException(500, f"创建用户失败: {exc}") from exc + user = result["user"] + vip = social_store.is_vip(user["id"]) + return { + "ok": True, + "user_id": user["id"], + "record": user, + "token": result["token"], + "is_new": bool(result.get("is_new")), + "vip": vip, + } + + +@router.post("/complete-order") +async def complete_order(payload: CompleteOrderPayload, authorization: str | None = Header(None)): + order_id = payload.order_id.strip() + if not order_id: + raise HTTPException(400, "缺少 order_id") + order = social_store.get_order(order_id) + if not order: + raise HTTPException(404, "订单不存在") + if order.get("status") != "paid": + paid = social_store.mark_order_paid(order_id) + if not paid: + raise HTTPException(400, "订单未支付成功") + order = paid + + user = None + token = None + if authorization and authorization.startswith("Bearer "): + user = get_user_by_token(authorization[7:]) + token = authorization[7:] + if not user: + uid = order.get("userId") or order.get("user_id") or "" + if uid: + user = {"id": uid, "email": "", "name": "会员", "avatar": "🧑‍💻"} + return { + "ok": True, + "token": token, + "record": user, + "is_new": False, + "vip": True, + "order_id": order_id, + } diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 9bcf65d..0db40e9 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -341,6 +341,36 @@ def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None return get_plan(token) +def ensure_user_by_email(email: str, name: str = "") -> dict[str, Any]: + """Create or resume a session for meetup checkout flows (NomadCNA ensure-user).""" + display = (name or email.split("@")[0]).strip() or "游民" + if use_pb(): + _migrate_json_to_pb() + user = _get_account_by_email(email) + is_new = False + if not user: + created = register_user(email, secrets.token_urlsafe(12), display) + if not created: + raise RuntimeError("unable to create user") + return {**created, "is_new": True} + return {**_issue_login(user), "is_new": is_new} + + if email not in _users: + created = register_user(email, secrets.token_urlsafe(12), display) + if not created: + raise RuntimeError("unable to create user") + return {**created, "is_new": True} + return {**_issue_login(_users[email]), "is_new": False} + + +def find_user_public_by_email(email: str) -> dict | None: + if use_pb(): + user = _get_account_by_email(email) + return _user_profile(user) if user else None + user = _users.get(email) + return _user_profile(user) if user else None + + # Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase if not use_pb(): if not _restore_json(): diff --git a/backend/app/services/city_service.py b/backend/app/services/city_service.py new file mode 100644 index 0000000..46d026b --- /dev/null +++ b/backend/app/services/city_service.py @@ -0,0 +1,138 @@ +"""City / destination depth aggregation (city_details + related content).""" +from __future__ import annotations + +from typing import Any + +from app.data.city_details_data import ( + CITY_DETAILS_BY_SLUG, + CONTENT_ITEMS, + EXTRA_ROUTES, +) +from app.data import community_data +from app.services.pb_repo import q, safe_first, safe_list, use_pb +from app.services.pocketbase import pb +from app.services import community_store + + +def get_city_detail_record(slug: str) -> dict[str, Any] | None: + if use_pb(): + row = safe_first("city_details", filter=f"citySlug={q(slug)}") + if row: + return { + "citySlug": row.get("citySlug", slug), + "guide": row.get("guide") or {}, + "pros": row.get("pros") or {}, + "reviews": row.get("reviews") or {}, + "cost": row.get("cost") or {}, + "people": row.get("people") or {}, + "chat": row.get("chat") or {}, + "photos": row.get("photos") or {}, + "weather": row.get("weather") or {}, + "trends": row.get("trends") or {}, + "demographics": row.get("demographics") or {}, + "relatedContent": row.get("relatedContent") or [], + } + return CITY_DETAILS_BY_SLUG.get(slug) + + +async def get_destination_full(slug: str) -> dict[str, Any] | None: + city = await pb.get_destination(slug) + if not city: + return None + + detail = get_city_detail_record(slug) + content: list[dict] = [] + if use_pb(): + for item in safe_list("content_items"): + slugs = item.get("citySlugs") or [] + if slug in slugs and item.get("status", "published") == "published": + content.append(_public_content(item)) + if not content: + content = [_public_content(c) for c in CONTENT_ITEMS if slug in (c.get("citySlugs") or [])] + + city_name = city.get("name", "") + meetups = [ + m for m in community_store.list_meetups(upcoming=True) + if m.get("destination_slug") == slug or m.get("city") == city_name + ][:10] + discussions = [ + d for d in community_store.list_discussions() + if city_name and (city_name in d.get("title", "") or city_name in d.get("excerpt", "")) + ][:10] + + return { + "ok": True, + "city": city, + "detail": detail, + "content": content, + "meetups": meetups, + "discussions": discussions, + } + + +def _public_content(item: dict) -> dict: + return { + "slug": item.get("slug", ""), + "type": item.get("type", "guide"), + "title": item.get("title", ""), + "subtitle": item.get("subtitle") or item.get("subtitleEn") or "", + "description": item.get("description", ""), + "targetUrl": item.get("targetUrl", ""), + "ctaLabel": item.get("ctaLabel", "查看"), + "citySlugs": item.get("citySlugs") or [], + } + + +def list_routes_enriched(destinations: list[dict] | None = None) -> list[dict]: + dest_by_slug = {d.get("slug"): d for d in (destinations or [])} + routes: list[dict] = [] + + if use_pb(): + for r in safe_list("routes"): + if r.get("status") and r.get("status") != "published": + continue + routes.append(_route_from_pb(r, dest_by_slug)) + + if not routes: + for r in community_data.NOMAD_ROUTES: + routes.append({ + "id": r.get("id") or r.get("slug", ""), + "slug": r.get("slug", ""), + "title": r.get("title", ""), + "duration_days": r.get("duration_days", 0), + "budget": r.get("budget", 0), + "description": r.get("description", ""), + "stops": r.get("stops") or [], + "emoji": r.get("emoji", "🗺️"), + "cities": [dest_by_slug[s] for s in (r.get("stops") or []) if s in dest_by_slug], + }) + for r in EXTRA_ROUTES: + if any(x["slug"] == r["slug"] for x in routes): + continue + routes.append({ + "id": r["slug"], + "slug": r["slug"], + "title": r["title"], + "duration_days": r.get("durationDays", 0), + "budget": r.get("budget", 0), + "description": r.get("description", ""), + "stops": r.get("citySlugs") or [], + "emoji": r.get("emoji", "🗺️"), + "cities": [dest_by_slug[s] for s in (r.get("citySlugs") or []) if s in dest_by_slug], + }) + return routes + + +def _route_from_pb(r: dict, dest_by_slug: dict) -> dict: + slugs = r.get("citySlugs") or [] + return { + "id": r.get("id") or r.get("slug", ""), + "slug": r.get("slug", ""), + "title": r.get("title", ""), + "duration_days": int(r.get("durationDays") or 0), + "budget": int(r.get("budget") or 0), + "description": r.get("description", ""), + "stops": slugs, + "emoji": r.get("emoji", "🗺️"), + "cities": [dest_by_slug[s] for s in slugs if s in dest_by_slug], + } diff --git a/backend/scripts/init_pb_collections.py b/backend/scripts/init_pb_collections.py index 3f74e50..16ca6bf 100644 --- a/backend/scripts/init_pb_collections.py +++ b/backend/scripts/init_pb_collections.py @@ -263,6 +263,57 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = { text("userId", required=True, max=80), json_field("channels"), ], + "city_details": [ + text("citySlug", required=True, max=120), + json_field("guide"), + json_field("pros"), + json_field("reviews"), + json_field("cost"), + json_field("people"), + json_field("chat"), + json_field("photos"), + json_field("weather"), + json_field("trends"), + json_field("demographics"), + json_field("relatedContent"), + ], + "routes": [ + text("slug", required=True, max=120), + text("title", required=True, max=255), + text("titleEn", max=255), + json_field("citySlugs"), + number("durationDays", only_int=True), + number("budget", only_int=True), + text("description", max=1000), + text("descriptionEn", max=1000), + json_field("stops"), + text("status", max=40), + text("emoji", max=16), + ], + "content_items": [ + text("slug", required=True, max=120), + text("type", required=True, max=40), + text("title", required=True, max=255), + text("titleEn", max=255), + text("subtitle", max=500), + text("description", max=2000), + text("coverImage", max=500), + text("mediaUrl", max=500), + text("targetUrl", max=500), + text("ctaLabel", max=120), + json_field("citySlugs"), + number("sortOrder", only_int=True), + text("status", max=40), + text("authorName", max=120), + ], + "recommendation_logs": [ + text("userId", max=80), + number("budget", only_int=True), + number("internet", only_int=True), + text("climate", max=80), + json_field("tags"), + json_field("resultSlugs"), + ], } @@ -365,7 +416,7 @@ def main() -> None: "legacyId": g.get("id", ""), "title": g.get("title", ""), "category": g.get("category", ""), - "budget": g.get("budget", 0), + "budget": g.get("budget", 0) if isinstance(g.get("budget"), int) else 0, "location": g.get("location", ""), "description": g.get("description", ""), "status": g.get("status", "open"), @@ -373,6 +424,30 @@ def main() -> None: for g in gigs ], ) + + from app.data.city_details_data import CITY_DETAILS, CONTENT_ITEMS, EXTRA_ROUTES + + seed_if_empty("city_details", CITY_DETAILS) + seed_if_empty( + "routes", + [ + { + "slug": r["slug"], + "title": r["title"], + "titleEn": r.get("titleEn", ""), + "citySlugs": r.get("citySlugs") or [], + "durationDays": r.get("durationDays", 0), + "budget": r.get("budget", 0), + "description": r.get("description", ""), + "descriptionEn": r.get("descriptionEn", ""), + "stops": r.get("stops") or [], + "status": r.get("status", "published"), + "emoji": r.get("emoji", "🗺️"), + } + for r in EXTRA_ROUTES + ], + ) + seed_if_empty("content_items", CONTENT_ITEMS) print("done") diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3728a15..3e23f17 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4445,6 +4445,76 @@ img { max-width: 100%; display: block; } margin: 28px 0; } +.dest-depth-block { + margin: 28px 0; + padding: 22px 0; + border-top: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent); +} + +.dest-depth-block h3 { + font-size: 1.05rem; + margin-bottom: 12px; +} + +.dest-depth-muted { + color: var(--text-muted); + font-size: 0.92rem; + margin-top: 8px; +} + +.dest-depth-tags { + margin-top: 10px; + font-size: 0.9rem; +} + +.dest-depth-list { + margin: 10px 0 0; + padding-left: 1.1rem; + display: grid; + gap: 6px; +} + +.dest-cost-grid { + display: grid; + gap: 8px; + max-width: 420px; +} + +.dest-cost-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 8px 0; + border-bottom: 1px dashed color-mix(in srgb, var(--text-muted) 25%, transparent); +} + +.dest-pros-cons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.dest-review-grid { + display: grid; + gap: 14px; +} + +.dest-review-grid blockquote { + margin: 0; + padding: 14px 0; + border-top: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent); +} + +.dest-review-grid footer { + margin-top: 8px; + color: var(--text-muted); + font-size: 0.85rem; +} + +@media (max-width: 720px) { + .dest-pros-cons { grid-template-columns: 1fr; } +} + .dest-radar-card { padding: 24px; background: var(--bg-glass); diff --git a/frontend/src/components/DestinationDetailClient.tsx b/frontend/src/components/DestinationDetailClient.tsx index 2332e14..213037f 100644 --- a/frontend/src/components/DestinationDetailClient.tsx +++ b/frontend/src/components/DestinationDetailClient.tsx @@ -7,7 +7,8 @@ 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 { api, type CityContentItem, type CityDetailPayload } from "@/lib/api"; +import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types"; const TZ_LABELS: Record = { bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7", @@ -34,6 +35,10 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props const { toast } = useToast(); const [added, setAdded] = useState(false); const [months, setMonths] = useState(1); + const [detail, setDetail] = useState(null); + const [content, setContent] = useState([]); + const [meetups, setMeetups] = useState([]); + const [discussions, setDiscussions] = useState([]); const scores = getScores(dest); const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]); const budget = useMemo(() => loadPlanMeta().monthlyBudget, []); @@ -43,6 +48,15 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props .filter((d) => d.region === dest.region && d.slug !== dest.slug) .slice(0, 3); + useEffect(() => { + api.getDestinationFull(dest.slug).then((full) => { + setDetail(full.detail); + setContent(full.content || []); + setMeetups(full.meetups || []); + setDiscussions(full.discussions || []); + }).catch(() => {}); + }, [dest.slug]); + const addToTrip = () => { const trip = loadTrip(); if (trip.some((t) => t.slug === dest.slug)) { @@ -75,6 +89,10 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props setAdded(trip.some((t) => t.slug === dest.slug)); }, [dest.slug]); + const costBreak = detail?.cost?.breakdown || []; + const guide = detail?.guide; + const pros = detail?.pros; + return ( <>
@@ -105,6 +123,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props ⚖️ 对比同区 )} + 🍹 同城活动 🗓️ 计划中心 📋 签证
@@ -122,6 +141,22 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props )} + {guide?.summary && ( +
+

🧭 落地指南

+

{guide.summary}

+ {guide.workSetup &&

{guide.workSetup}

} + {!!guide.bestFor?.length && ( +

适合:{guide.bestFor.join(" · ")}

+ )} + {!!guide.arrivalChecklist?.length && ( +
    + {guide.arrivalChecklist.map((item) =>
  • {item}
  • )} +
+ )} +
+ )} +

📊 城市画像

@@ -150,19 +185,125 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props 👥
游民社区 - {dest.nomads_count} 活跃 + {detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} 活跃
- 🌏 + 🌡️
- 区域 - {dest.tag.split("·")[0]?.trim()} + 气候 + + {detail?.weather?.temperature ?? dest.temperature}°C + {detail?.weather?.bestMonths ? ` · 宜居 ${detail.weather.bestMonths.join("/")}` : ""} +
+ {!!costBreak.length && ( +
+

💸 月度成本拆解

+
+ {costBreak.map((row) => ( +
+ {row.label} + ¥{row.amount.toLocaleString()} +
+ ))} +
+ {detail?.cost?.tip &&

{detail.cost.tip}

} +
+ )} + + {(pros?.pros || pros?.cons) && ( +
+
+

👍 优势

+
    {(pros?.pros || []).map((p) =>
  • {p}
  • )}
+
+
+

👎 注意

+
    {(pros?.cons || []).map((p) =>
  • {p}
  • )}
+
+
+ )} + + {!!detail?.reviews?.items?.length && ( +
+

💬 游民评价 · {detail.reviews.rating}/10

+
+ {detail.reviews.items.map((r) => ( +
+

{r.text}

+
{r.author} · {r.role} · {"★".repeat(r.score)}
+
+ ))} +
+
+ )} + + {!!detail?.chat?.channels?.length && ( +
+

💬 同城社群

+
    + {detail.chat.channels.map((c) => ( +
  • {c.name} · {c.members} 人 · {c.status}
  • + ))} +
+ {!!detail.chat.latestTopics?.length && ( +

近期话题:{detail.chat.latestTopics.join(" · ")}

+ )} +
+ )} + + {!!meetups.length && ( +
+

🍹 相关活动

+
+ {meetups.slice(0, 4).map((m) => ( + + {m.emoji || "🎉"} +
+ {m.title} + {m.date} · {m.city} +
+ + ))} +
+
+ )} + + {!!discussions.length && ( +
+

🧵 相关讨论

+
    + {discussions.slice(0, 5).map((d) => ( +
  • + {d.title} +
  • + ))} +
+
+ )} + + {!!content.length && ( +
+

📚 相关内容

+
+ {content.map((c) => ( + + 📄 +
+ {c.title} + {c.subtitle || c.ctaLabel || "查看"} +
+ + ))} +
+
+ )} + {related.length > 0 && (

🔗 同区域推荐

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2f0da69..6e622ab 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,6 +6,48 @@ import type { ServiceItem, Stats, SwipeResult, SyncedUserPlan, Testimonial, Tool, VideoItem, VipStatus, Visa, WeatherCity, } from "./types"; +export type CityContentItem = { + slug: string; + type: string; + title: string; + subtitle?: string; + description?: string; + targetUrl?: string; + ctaLabel?: string; +}; + +export type CityDetailPayload = { + citySlug: string; + guide?: { + summary?: string; + bestFor?: string[]; + workSetup?: string; + arrivalChecklist?: string[]; + }; + pros?: { pros?: string[]; cons?: string[] }; + reviews?: { rating?: number; items?: { author: string; role: string; text: string; score: number }[] }; + cost?: { + monthlyTotal?: number; + currency?: string; + breakdown?: { label: string; amount: number }[]; + tip?: string; + }; + people?: { + nomadsNow?: number; + personas?: { name: string; percent: number }[]; + }; + chat?: { + channels?: { name: string; members: number; status: string }[]; + latestTopics?: string[]; + }; + weather?: { temperature?: number; humidity?: number; bestMonths?: string[]; note?: string }; + trends?: { growth?: { month: string; value: number }[]; insight?: string }; + demographics?: { + age?: { label: string; value: number }[]; + work?: { label: string; value: number }[]; + }; +}; + const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; // Fallback data when API is unavailable (build time / offline) @@ -48,11 +90,30 @@ export const api = { return fetchAPI(`/destinations${qs ? `?${qs}` : ""}`); }, getDestination: (slug: string) => fetchAPI(`/destinations/${slug}`), + getDestinationFull: (slug: string) => + fetchAPI<{ + ok: boolean; + city: Destination; + detail: CityDetailPayload | null; + content: CityContentItem[]; + meetups: Meetup[]; + discussions: Discussion[]; + }>(`/destinations/${slug}/full`), compareDestinations: (slugs: string[]) => fetchAPI("/destinations/compare", { method: "POST", body: JSON.stringify({ slugs }), }), + meetupCheckUser: (email: string) => + fetchAPI<{ ok: boolean; exists: boolean; vip: boolean; user_id: string | null }>("/meetup/check-user", { + method: "POST", + body: JSON.stringify({ email }), + }), + meetupEnsureUser: (email: string, name?: string) => + fetchAPI("/meetup/ensure-user", { + method: "POST", + body: JSON.stringify({ email, name: name || "" }), + }), getVisas: () => fetchWithFallback("/visas", []), getFaqs: () => fetchWithFallback("/faqs", []), getTestimonials: () => fetchWithFallback("/testimonials", []), diff --git a/scripts/deploy_backend_hotfix.py b/scripts/deploy_backend_hotfix.py index 0d7826c..e40167d 100644 --- a/scripts/deploy_backend_hotfix.py +++ b/scripts/deploy_backend_hotfix.py @@ -27,7 +27,12 @@ FILES = [ "backend/app/services/community_store.py", "backend/app/services/social_store.py", "backend/app/services/platform_store.py", + "backend/app/services/city_service.py", + "backend/app/data/city_details_data.py", + "backend/app/data/community_data.py", "backend/app/routers/media.py", + "backend/app/routers/meetup_auth.py", + "backend/app/routers/api.py", "backend/requirements.txt", "backend/scripts/init_pb_collections.py", "deploy/systemd/nomadro-api.service",