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, "duration_days": 90,
"budget": 18000, "budget": 18000,
"description": "清迈 → 巴厘岛 → 吉隆坡,低成本 + 高速网络 + 成熟游民社区。", "description": "清迈 → 巴厘岛 → 吉隆坡,低成本 + 高速网络 + 成熟游民社区。",
"stops": ["chiang-mai", "bali", "kuala-lumpur"], "stops": ["chiangmai", "bali"],
"emoji": "🌴", "emoji": "🌴",
}, },
{ {
@ -247,8 +247,8 @@ NOMAD_ROUTES = [
"title": "欧洲文化环线", "title": "欧洲文化环线",
"duration_days": 60, "duration_days": 60,
"budget": 35000, "budget": 35000,
"description": "里斯本 → 巴塞罗那 → 柏林,签证友好、文化丰富。", "description": "里斯本 → 巴塞罗那,签证友好、文化丰富。",
"stops": ["lisbon", "barcelona", "berlin"], "stops": ["lisbon", "barcelona"],
"emoji": "🏰", "emoji": "🏰",
}, },
{ {
@ -257,8 +257,18 @@ NOMAD_ROUTES = [
"title": "拉美性价比冲刺", "title": "拉美性价比冲刺",
"duration_days": 45, "duration_days": 45,
"budget": 12000, "budget": 12000,
"description": "墨西哥城 → 麦德林,贴近北美时区、生活成本低。", "description": "墨西哥城深度试住,贴近北美时区、生活成本低。",
"stops": ["mexico-city", "medellin"], "stops": ["mexico"],
"emoji": "🌮", "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 fastapi.middleware.cors import CORSMiddleware
from app.config import settings 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( app = FastAPI(
title="nomadro API", title="nomadro API",
@ -20,6 +20,7 @@ app.add_middleware(
app.include_router(api.router, prefix="/api/v1") app.include_router(api.router, prefix="/api/v1")
app.include_router(auth.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(social.router, prefix="/api/v1")
app.include_router(community.router, prefix="/api/v1") app.include_router(community.router, prefix="/api/v1")
app.include_router(payment.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.auth import get_user_by_token
from app.services import community_store, social_store from app.services import community_store, social_store
from app.services import city_service
from app.data import digital_content from app.data import digital_content
router = APIRouter() router = APIRouter()
@ -64,6 +65,24 @@ async def get_destination(slug: str):
return Destination(**data) 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]) @router.post("/destinations/compare", response_model=list[Destination])
async def compare_destinations(body: CompareRequest): async def compare_destinations(body: CompareRequest):
all_dests = await pb.get_destinations() all_dests = await pb.get_destinations()
@ -271,7 +290,14 @@ async def get_discussion(discussion_id: str):
@router.get("/routes", response_model=list[NomadRoute]) @router.get("/routes", response_model=list[NomadRoute])
async def list_routes(): 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) @router.post("/recommendations/next-stop", response_model=NextStopResponse)
@ -286,9 +312,10 @@ async def next_stop_recommendations(body: NextStopRequest):
priority=body.priority, priority=body.priority,
limit=body.limit, limit=body.limit,
) )
routes = city_service.list_routes_enriched(dests)
return NextStopResponse( return NextStopResponse(
items=[RecommendedDestination(**d) for d in recommended], 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) 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 # Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
if not use_pb(): if not use_pb():
if not _restore_json(): 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), text("userId", required=True, max=80),
json_field("channels"), 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", ""), "legacyId": g.get("id", ""),
"title": g.get("title", ""), "title": g.get("title", ""),
"category": g.get("category", ""), "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", ""), "location": g.get("location", ""),
"description": g.get("description", ""), "description": g.get("description", ""),
"status": g.get("status", "open"), "status": g.get("status", "open"),
@ -373,6 +424,30 @@ def main() -> None:
for g in gigs 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") print("done")

View File

@ -4445,6 +4445,76 @@ img { max-width: 100%; display: block; }
margin: 28px 0; 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 { .dest-radar-card {
padding: 24px; padding: 24px;
background: var(--bg-glass); background: var(--bg-glass);

View File

@ -7,7 +7,8 @@ import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, stayHint } from "@/lib/planMeta"; import { loadPlanMeta, stayHint } from "@/lib/planMeta";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { compareUrl } from "@/lib/compareScore"; 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> = { const TZ_LABELS: Record<string, string> = {
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7", bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
@ -34,6 +35,10 @@ 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 [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 scores = getScores(dest);
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]); const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []); 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) .filter((d) => d.region === dest.region && d.slug !== dest.slug)
.slice(0, 3); .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 addToTrip = () => {
const trip = loadTrip<TripItem>(); const trip = loadTrip<TripItem>();
if (trip.some((t) => t.slug === dest.slug)) { 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)); setAdded(trip.some((t) => t.slug === dest.slug));
}, [dest.slug]); }, [dest.slug]);
const costBreak = detail?.cost?.breakdown || [];
const guide = detail?.guide;
const pros = detail?.pros;
return ( return (
<> <>
<div className="dest-detail-actions"> <div className="dest-detail-actions">
@ -105,6 +123,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
⚖️ 对比同区 ⚖️ 对比同区
</Link> </Link>
)} )}
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
<Link href="/plan" className="btn btn-ghost">🗓️ 计划中心</Link> <Link href="/plan" className="btn btn-ghost">🗓️ 计划中心</Link>
<Link href="/#visa" className="btn btn-ghost">📋 签证</Link> <Link href="/#visa" className="btn btn-ghost">📋 签证</Link>
</div> </div>
@ -122,6 +141,22 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
</div> </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-detail-extras">
<div className="dest-radar-card"> <div className="dest-radar-card">
<h3>📊 城市画像</h3> <h3>📊 城市画像</h3>
@ -150,19 +185,125 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<span>👥</span> <span>👥</span>
<div> <div>
<strong>游民社区</strong> <strong>游民社区</strong>
<span>{dest.nomads_count} 活跃</span> <span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} 活跃</span>
</div> </div>
</div> </div>
<div className="dest-info-mini"> <div className="dest-info-mini">
<span>🌏</span> <span>🌡️</span>
<div> <div>
<strong>区域</strong> <strong>气候</strong>
<span>{dest.tag.split("·")[0]?.trim()}</span> <span>
{detail?.weather?.temperature ?? dest.temperature}°C
{detail?.weather?.bestMonths ? ` · 宜居 ${detail.weather.bestMonths.join("/")}` : ""}
</span>
</div> </div>
</div> </div>
</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 && ( {related.length > 0 && (
<div className="dest-related"> <div className="dest-related">
<h3>🔗 同区域推荐</h3> <h3>🔗 同区域推荐</h3>

View File

@ -6,6 +6,48 @@ import type {
ServiceItem, Stats, SwipeResult, SyncedUserPlan, Testimonial, Tool, VideoItem, VipStatus, Visa, WeatherCity, ServiceItem, Stats, SwipeResult, SyncedUserPlan, Testimonial, Tool, VideoItem, VipStatus, Visa, WeatherCity,
} from "./types"; } 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"; const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
// Fallback data when API is unavailable (build time / offline) // Fallback data when API is unavailable (build time / offline)
@ -48,11 +90,30 @@ export const api = {
return fetchAPI<Destination[]>(`/destinations${qs ? `?${qs}` : ""}`); return fetchAPI<Destination[]>(`/destinations${qs ? `?${qs}` : ""}`);
}, },
getDestination: (slug: string) => fetchAPI<Destination>(`/destinations/${slug}`), 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[]) => compareDestinations: (slugs: string[]) =>
fetchAPI<Destination[]>("/destinations/compare", { fetchAPI<Destination[]>("/destinations/compare", {
method: "POST", method: "POST",
body: JSON.stringify({ slugs }), 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", []), getVisas: () => fetchWithFallback<Visa[]>("/visas", []),
getFaqs: () => fetchWithFallback<FAQ[]>("/faqs", []), getFaqs: () => fetchWithFallback<FAQ[]>("/faqs", []),
getTestimonials: () => fetchWithFallback<Testimonial[]>("/testimonials", []), getTestimonials: () => fetchWithFallback<Testimonial[]>("/testimonials", []),

View File

@ -27,7 +27,12 @@ FILES = [
"backend/app/services/community_store.py", "backend/app/services/community_store.py",
"backend/app/services/social_store.py", "backend/app/services/social_store.py",
"backend/app/services/platform_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/media.py",
"backend/app/routers/meetup_auth.py",
"backend/app/routers/api.py",
"backend/requirements.txt", "backend/requirements.txt",
"backend/scripts/init_pb_collections.py", "backend/scripts/init_pb_collections.py",
"deploy/systemd/nomadro-api.service", "deploy/systemd/nomadro-api.service",