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 <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 11:54:10 -05:00
parent 5280c5f62a
commit 9b3f210bd2
12 changed files with 895 additions and 14 deletions

View File

@ -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",
},
]

View File

@ -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": "🚄",
},
]

View File

@ -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")

View File

@ -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],
)

View File

@ -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,
}

View File

@ -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():

View File

@ -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],
}

View File

@ -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")

View File

@ -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);

View File

@ -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<string, string> = {
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<CityDetailPayload | null>(null);
const [content, setContent] = useState<CityContentItem[]>([]);
const [meetups, setMeetups] = useState<Meetup[]>([]);
const [discussions, setDiscussions] = useState<Discussion[]>([]);
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<TripItem>();
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 (
<>
<div className="dest-detail-actions">
@ -105,6 +123,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
⚖️ 对比同区
</Link>
)}
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
<Link href="/plan" className="btn btn-ghost">🗓️ 计划中心</Link>
<Link href="/#visa" className="btn btn-ghost">📋 签证</Link>
</div>
@ -122,6 +141,22 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
</div>
)}
{guide?.summary && (
<section className="dest-depth-block">
<h3>🧭 落地指南</h3>
<p>{guide.summary}</p>
{guide.workSetup && <p className="dest-depth-muted">{guide.workSetup}</p>}
{!!guide.bestFor?.length && (
<p className="dest-depth-tags">适合:{guide.bestFor.join(" · ")}</p>
)}
{!!guide.arrivalChecklist?.length && (
<ul className="dest-depth-list">
{guide.arrivalChecklist.map((item) => <li key={item}>{item}</li>)}
</ul>
)}
</section>
)}
<div className="dest-detail-extras">
<div className="dest-radar-card">
<h3>📊 城市画像</h3>
@ -150,19 +185,125 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<span>👥</span>
<div>
<strong>游民社区</strong>
<span>{dest.nomads_count} 活跃</span>
<span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} 活跃</span>
</div>
</div>
<div className="dest-info-mini">
<span>🌏</span>
<span>🌡️</span>
<div>
<strong>区域</strong>
<span>{dest.tag.split("·")[0]?.trim()}</span>
<strong>气候</strong>
<span>
{detail?.weather?.temperature ?? dest.temperature}°C
{detail?.weather?.bestMonths ? ` · 宜居 ${detail.weather.bestMonths.join("/")}` : ""}
</span>
</div>
</div>
</div>
</div>
{!!costBreak.length && (
<section className="dest-depth-block">
<h3>💸 月度成本拆解</h3>
<div className="dest-cost-grid">
{costBreak.map((row) => (
<div key={row.label} className="dest-cost-row">
<span>{row.label}</span>
<strong>¥{row.amount.toLocaleString()}</strong>
</div>
))}
</div>
{detail?.cost?.tip && <p className="dest-depth-muted">{detail.cost.tip}</p>}
</section>
)}
{(pros?.pros || pros?.cons) && (
<section className="dest-depth-block dest-pros-cons">
<div>
<h3>👍 优势</h3>
<ul>{(pros?.pros || []).map((p) => <li key={p}>{p}</li>)}</ul>
</div>
<div>
<h3>👎 注意</h3>
<ul>{(pros?.cons || []).map((p) => <li key={p}>{p}</li>)}</ul>
</div>
</section>
)}
{!!detail?.reviews?.items?.length && (
<section className="dest-depth-block">
<h3>💬 游民评价 · {detail.reviews.rating}/10</h3>
<div className="dest-review-grid">
{detail.reviews.items.map((r) => (
<blockquote key={`${r.author}-${r.text.slice(0, 12)}`}>
<p>{r.text}</p>
<footer>{r.author} · {r.role} · {"★".repeat(r.score)}</footer>
</blockquote>
))}
</div>
</section>
)}
{!!detail?.chat?.channels?.length && (
<section className="dest-depth-block">
<h3>💬 同城社群</h3>
<ul className="dest-depth-list">
{detail.chat.channels.map((c) => (
<li key={c.name}>{c.name} · {c.members} 人 · {c.status}</li>
))}
</ul>
{!!detail.chat.latestTopics?.length && (
<p className="dest-depth-muted">近期话题:{detail.chat.latestTopics.join(" · ")}</p>
)}
</section>
)}
{!!meetups.length && (
<section className="dest-depth-block">
<h3>🍹 相关活动</h3>
<div className="dest-related-grid">
{meetups.slice(0, 4).map((m) => (
<Link key={m.id} href="/meetups" className="dest-related-card">
<span>{m.emoji || "🎉"}</span>
<div>
<strong>{m.title}</strong>
<span>{m.date} · {m.city}</span>
</div>
</Link>
))}
</div>
</section>
)}
{!!discussions.length && (
<section className="dest-depth-block">
<h3>🧵 相关讨论</h3>
<ul className="dest-depth-list">
{discussions.slice(0, 5).map((d) => (
<li key={d.id}>
<Link href={`/community/${d.id}`}>{d.title}</Link>
</li>
))}
</ul>
</section>
)}
{!!content.length && (
<section className="dest-depth-block">
<h3>📚 相关内容</h3>
<div className="dest-related-grid">
{content.map((c) => (
<Link key={c.slug} href={c.targetUrl || "/digital"} className="dest-related-card">
<span>📄</span>
<div>
<strong>{c.title}</strong>
<span>{c.subtitle || c.ctaLabel || "查看"}</span>
</div>
</Link>
))}
</div>
</section>
)}
{related.length > 0 && (
<div className="dest-related">
<h3>🔗 同区域推荐</h3>

View File

@ -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<Destination[]>(`/destinations${qs ? `?${qs}` : ""}`);
},
getDestination: (slug: string) => fetchAPI<Destination>(`/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<Destination[]>("/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<AuthResponse & { ok: boolean; is_new: boolean; vip: boolean; user_id: string }>("/meetup/ensure-user", {
method: "POST",
body: JSON.stringify({ email, name: name || "" }),
}),
getVisas: () => fetchWithFallback<Visa[]>("/visas", []),
getFaqs: () => fetchWithFallback<FAQ[]>("/faqs", []),
getTestimonials: () => fetchWithFallback<Testimonial[]>("/testimonials", []),

View File

@ -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",