diff --git a/.gitignore b/.gitignore index 65e945d..9bf6669 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ frontend/.env.local # Auth / plan user store (runtime) backend/app/data/user_store.json backend/app/data/social_store.json -backend/app/data/community_store.json +backend/app/data/platform_store.json # Production secrets (use deploy/nomadro-api.env.example) deploy/nomadro-api.env diff --git a/backend/app/data/platform_data.py b/backend/app/data/platform_data.py new file mode 100644 index 0000000..fec8adf --- /dev/null +++ b/backend/app/data/platform_data.py @@ -0,0 +1,116 @@ +"""Platform content: services, videos, help, member map seeds.""" + +SERVICES = [ + { + "id": "visa-consult", + "title": "签证方案咨询", + "description": "一对一梳理 D7/D8/DTV 等长期签证路径,含材料清单与时间线。", + "emoji": "📋", + "price": "¥299 起", + "category": "签证", + "provider": "nomadro 认证顾问", + }, + { + "id": "tax-review", + "title": "税务居民评估", + "description": "跨境收入、183 天规则与双重征税协定初步评估(非正式法律意见)。", + "emoji": "🧾", + "price": "¥399 起", + "category": "税务", + "provider": "合作会计师", + }, + { + "id": "relocation", + "title": "落地安家陪跑", + "description": "选房区、办 SIM、开户、联合办公 —— 7 天线上陪跑。", + "emoji": "🏠", + "price": "¥899 起", + "category": "落地", + "provider": "城市志愿者", + }, + { + "id": "remote-job", + "title": "远程简历优化", + "description": "面向欧美远程岗位的英文简历与 LinkedIn 优化。", + "emoji": "💼", + "price": "¥199 起", + "category": "职业", + "provider": "HR 志愿者", + }, +] + +VIDEOS = [ + { + "id": "chiangmai-2025", + "slug": "chiangmai-2025", + "title": "清迈:月入 8000 的游民一天", + "excerpt": "联合办公、夜市、签证续签与社区活动实录。", + "emoji": "🌴", + "duration": "18:42", + "guest": "小林", + "city": "清迈", + "published_at": "2026-03-12", + "tags": ["泰国", "成本", "社区"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "lisbon-d7", + "slug": "lisbon-d7", + "title": "里斯本 D7 签证上岸访谈", + "excerpt": "从材料准备到 NIF、银行账户的完整时间线。", + "emoji": "🇵🇹", + "duration": "24:10", + "guest": "Marco", + "city": "里斯本", + "published_at": "2026-02-08", + "tags": ["葡萄牙", "签证"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "bali-cowork", + "slug": "bali-cowork", + "title": "巴厘岛联合办公巡礼", + "excerpt": "对比 Canggu / Ubud 五家热门空间网速与月票。", + "emoji": "🏝️", + "duration": "15:33", + "guest": "Yuki", + "city": "巴厘岛", + "published_at": "2026-01-20", + "tags": ["印尼", "办公"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, +] + +MEMBER_MAP = [ + {"name": "小林", "city": "清迈", "lat": 18.79, "lng": 98.98, "emoji": "🧑‍💻"}, + {"name": "Marco", "city": "里斯本", "lat": 38.72, "lng": -9.14, "emoji": "🇵🇹"}, + {"name": "Yuki", "city": "巴厘岛", "lat": -8.34, "lng": 115.09, "emoji": "🏝️"}, + {"name": "Alex", "city": "墨西哥城", "lat": 19.43, "lng": -99.13, "emoji": "🌮"}, + {"name": "Sofia", "city": "巴塞罗那", "lat": 41.39, "lng": 2.17, "emoji": "☀️"}, + {"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"}, + {"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"}, + {"name": "Omar", "city": "迪拜", "lat": 25.20, "lng": 55.27, "emoji": "🏙️"}, +] + +CITY_RANKING = [ + {"slug": "chiangmai", "name": "清迈", "score": 92, "nomads": "12k+"}, + {"slug": "lisbon", "name": "里斯本", "score": 89, "nomads": "8k+"}, + {"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"}, + {"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"}, + {"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"}, +] + +DAILY_TIPS = { + "1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"}, + "2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"}, + "3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"}, +} + +WEATHER_CITIES = { + "chiangmai": {"lat": 18.79, "lng": 98.98, "name": "清迈"}, + "lisbon": {"lat": 38.72, "lng": -9.14, "name": "里斯本"}, + "bali": {"lat": -8.34, "lng": 115.09, "name": "巴厘岛"}, + "mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"}, + "barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"}, + "tokyo": {"lat": 35.68, "lng": 139.69, "name": "东京"}, +} diff --git a/backend/app/main.py b/backend/app/main.py index 2d845fd..8b6fb02 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, payment, social +from app.routers import api, auth, community, payment, platform, social app = FastAPI( title="nomadro API", @@ -22,6 +22,8 @@ app.include_router(api.router, prefix="/api/v1") app.include_router(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") +app.include_router(platform.router, prefix="/api/v1") @app.get("/") diff --git a/backend/app/routers/api.py b/backend/app/routers/api.py index f71e5c9..86ab3fb 100644 --- a/backend/app/routers/api.py +++ b/backend/app/routers/api.py @@ -263,7 +263,7 @@ async def list_discussions(category: str | None = Query(None)): @router.get("/discussions/{discussion_id}", response_model=DiscussionDetail) async def get_discussion(discussion_id: str): - d = community_store.get_discussion(discussion_id) + d = community_store.get_discussion(discussion_id, increment_view=True) if not d: raise HTTPException(404, "讨论不存在") return DiscussionDetail(**d) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a06e7f8..3dca497 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,13 +1,15 @@ from fastapi import APIRouter, Header, HTTPException +import httpx + from app.schemas import ( - AuthLogin, AuthRegister, AuthResponse, Destination, + AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest, FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile, UserPlanPayload, ) from app.services.auth import ( get_favorites, get_plan, get_user_by_token, - login_demo, login_user, register_user, save_plan, toggle_favorite, + login_demo, login_google, login_user, register_user, save_plan, toggle_favorite, ) from app.services.pocketbase import pb @@ -48,6 +50,29 @@ async def demo_login(): return AuthResponse(**result) +@router.post("/google", response_model=AuthResponse) +async def google_login(body: GoogleLoginRequest): + try: + async with httpx.AsyncClient(timeout=10) as client: + res = await client.get( + "https://oauth2.googleapis.com/tokeninfo", + params={"id_token": body.id_token}, + ) + res.raise_for_status() + data = res.json() + except httpx.HTTPError: + raise HTTPException(401, "Google 登录验证失败") + email = data.get("email") + verified = data.get("email_verified") + if not email or verified not in (True, "true"): + raise HTTPException(401, "无效的 Google 账号") + name = data.get("name") or email.split("@")[0] + result = login_google(email, name) + if not result: + raise HTTPException(500, "登录失败") + return AuthResponse(**result) + + @router.get("/favorites", response_model=FavoriteResponse) async def list_favorites(authorization: str | None = Header(None)): token = _extract_token(authorization) diff --git a/backend/app/routers/community.py b/backend/app/routers/community.py index 245856a..a700de9 100644 --- a/backend/app/routers/community.py +++ b/backend/app/routers/community.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Header, HTTPException, Query from app.schemas import ( - CreateDiscussionRequest, CreateMeetupRequest, CreateReplyRequest, + CreateDiscussionRequest, CreateGigRequest, CreateMeetupRequest, CreateReplyRequest, FeedbackRequest, GigApplyRequest, GigItem, NotificationItem, StatsOverview, ) from app.services.auth import get_user_by_token @@ -74,6 +74,13 @@ async def list_gigs(): return [GigItem(**g) for g in community_store.list_gigs()] +@router.post("/gigs") +async def create_gig(body: CreateGigRequest, authorization: str | None = Header(None)): + user = _user(authorization) + g = community_store.create_gig(user["id"], user["name"], body.model_dump()) + return {"success": True, "gig": g} + + @router.post("/gigs/{gig_id}/apply") async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | None = Header(None)): user = _user(authorization) @@ -96,6 +103,17 @@ async def mark_read(authorization: str | None = Header(None)): return {"success": True} +@router.post("/notifications/{notif_id}/{action}") +async def notification_action(notif_id: str, action: str, authorization: str | None = Header(None)): + user = _user(authorization) + if action not in ("read", "unread", "archive", "restore", "pin", "unpin"): + raise HTTPException(400, "无效操作") + n = community_store.update_notification(user["id"], notif_id, action) + if not n: + raise HTTPException(404, "通知不存在") + return {"success": True, "notification": n} + + @router.post("/feedback") async def submit_feedback(body: FeedbackRequest, authorization: str | None = Header(None)): user = None diff --git a/backend/app/routers/platform.py b/backend/app/routers/platform.py new file mode 100644 index 0000000..8941015 --- /dev/null +++ b/backend/app/routers/platform.py @@ -0,0 +1,185 @@ +"""Platform APIs: services, videos, weather, AI, stats, content.""" + +from fastapi import APIRouter, Header, HTTPException, Query + +from app.schemas import ( + AiAssistantRequest, AiAssistantResponse, ContentSubmissionRequest, + NewsletterRequest, ReportRequest, ServiceItem, ServiceLeadRequest, + VideoItem, VolunteerRequest, +) +from app.data import platform_data +from app.services import community_store, social_store +from app.services.auth import get_user_by_token +from app.services import platform_store +from app.services.weather_service import get_city_weather, list_weather +from app.services.ai_service import assistant_reply +from app.services.pocketbase import pb + +router = APIRouter(tags=["platform"]) + + +def _optional_user(authorization: str | None) -> dict | None: + if authorization and authorization.startswith("Bearer "): + return get_user_by_token(authorization[7:]) + return None + + +def _user(authorization: str | None) -> dict: + u = _optional_user(authorization) + if not u: + raise HTTPException(401, "未登录") + return u + + +@router.get("/services", response_model=list[ServiceItem]) +async def list_services(): + return [ServiceItem(**s) for s in platform_data.SERVICES] + + +@router.post("/services/leads") +async def service_lead(body: ServiceLeadRequest, authorization: str | None = Header(None)): + user = _optional_user(authorization) + item = platform_store.add_service_lead( + body.service_id, + user["id"] if user else None, + body.name, + body.email, + body.message, + ) + return {"success": True, "id": item["id"]} + + +@router.get("/videos", response_model=list[VideoItem]) +async def list_videos(): + return [VideoItem(**v) for v in platform_data.VIDEOS] + + +@router.get("/videos/{slug}", response_model=VideoItem) +async def get_video(slug: str): + v = next((x for x in platform_data.VIDEOS if x["slug"] == slug), None) + if not v: + raise HTTPException(404, "视频不存在") + return VideoItem(**v) + + +@router.get("/content/home") +async def content_home(): + return { + "featured": platform_data.VIDEOS[:2], + "videos_count": len(platform_data.VIDEOS), + "services_count": len(platform_data.SERVICES), + } + + +@router.post("/content/submissions") +async def content_submission(body: ContentSubmissionRequest, authorization: str | None = Header(None)): + user = _optional_user(authorization) + item = platform_store.add_content_submission( + user["id"] if user else None, + body.name, + body.title, + body.content_type, + body.url, + body.notes, + ) + return {"success": True, "id": item["id"]} + + +@router.get("/weather/cities") +async def weather_cities(): + return await list_weather() + + +@router.get("/weather/cities/{slug}") +async def weather_city(slug: str): + w = await get_city_weather(slug) + if not w: + raise HTTPException(404, "城市不存在") + return w + + +@router.post("/ai/assistant", response_model=AiAssistantResponse) +async def ai_assistant(body: AiAssistantRequest): + dests = await pb.get_destinations() + result = assistant_reply(body.message, dests) + return AiAssistantResponse(**result) + + +@router.post("/newsletter/subscribe") +async def newsletter_subscribe(body: NewsletterRequest): + res = platform_store.subscribe_newsletter(body.email, body.source) + return res + + +@router.get("/stats/member-map") +async def member_map(): + return {"members": platform_data.MEMBER_MAP} + + +@router.get("/stats/city-ranking") +async def city_ranking(): + return {"items": platform_data.CITY_RANKING} + + +@router.post("/cities/{slug}/volunteer-applications") +async def volunteer_apply(slug: str, body: VolunteerRequest, authorization: str | None = Header(None)): + user = _optional_user(authorization) + item = platform_store.add_volunteer(slug, user["id"] if user else None, body.name, body.email, body.message) + return {"success": True, "id": item["id"]} + + +@router.post("/reports") +async def submit_report(body: ReportRequest, authorization: str | None = Header(None)): + user = _optional_user(authorization) + item = platform_store.add_report( + user["id"] if user else None, + body.target_type, + body.target_id, + body.reason, + ) + return {"success": True, "id": item["id"]} + + +@router.get("/digital/day/{day_id}") +async def digital_day(day_id: str): + tip = platform_data.DAILY_TIPS.get(day_id) + if not tip: + raise HTTPException(404, "每日指南不存在") + return {"id": day_id, **tip} + + +@router.get("/notifications/unread-count") +async def unread_count(authorization: str | None = Header(None)): + user = _user(authorization) + return {"count": community_store.notification_unread_count(user["id"])} + + +@router.get("/notifications/preferences") +async def get_notif_prefs(authorization: str | None = Header(None)): + user = _user(authorization) + return platform_store.get_notif_prefs(user["id"]) + + +@router.put("/notifications/preferences") +async def set_notif_prefs(body: dict, authorization: str | None = Header(None)): + user = _user(authorization) + return platform_store.set_notif_prefs(user["id"], body) + + +@router.get("/discussions/search") +async def search_discussions(q: str = Query(..., min_length=1)): + return community_store.search_discussions(q) + + +@router.get("/meetups/{meetup_id}/social-suggestions") +async def meetup_social(meetup_id: str): + return {"items": community_store.meetup_social_suggestions(meetup_id)} + + +@router.delete("/meetups/{meetup_id}/rsvp") +async def cancel_rsvp(meetup_id: str, authorization: str | None = Header(None)): + user = _user(authorization) + res = community_store.cancel_rsvp(meetup_id, user["id"]) + if not res.get("ok"): + raise HTTPException(400, "未报名此活动") + return res diff --git a/backend/app/routers/social.py b/backend/app/routers/social.py index 08f0b81..ea01048 100644 --- a/backend/app/routers/social.py +++ b/backend/app/routers/social.py @@ -67,6 +67,12 @@ async def undo_swipe(authorization: str | None = Header(None)): return res +@router.get("/matches/likes/received", response_model=list[MatchProfile]) +async def match_likes_received(authorization: str | None = Header(None)): + user = _token_user(authorization) + return [MatchProfile(**p) for p in social_store.list_likes_received(user["id"])] + + @router.get("/matches/likes", response_model=list[MatchProfile]) async def match_likes(authorization: str | None = Header(None)): user = _token_user(authorization) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 03d6efd..6f0bfa6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -456,9 +456,89 @@ class NotificationItem(BaseModel): body: str = "" link: str = "" read: bool = False + archived: bool = False + pinned: bool = False + category: str = "general" created_at: str = "" +class CreateGigRequest(BaseModel): + title: str = Field(min_length=4, max_length=120) + description: str = Field(min_length=10, max_length=4000) + budget: str = "面议" + deadline: str = "" + tags: list[str] = Field(default_factory=list) + + +class ServiceItem(BaseModel): + id: str + title: str + description: str + emoji: str = "💼" + price: str = "" + category: str = "" + provider: str = "" + + +class ServiceLeadRequest(BaseModel): + service_id: str + name: str = Field(min_length=2, max_length=80) + email: EmailStr + message: str = Field(min_length=4, max_length=2000) + + +class VideoItem(BaseModel): + id: str + slug: str + title: str + excerpt: str = "" + emoji: str = "🎬" + duration: str = "" + guest: str = "" + city: str = "" + published_at: str = "" + tags: list[str] = Field(default_factory=list) + video_url: str = "" + + +class AiAssistantRequest(BaseModel): + message: str = Field(min_length=2, max_length=500) + + +class AiAssistantResponse(BaseModel): + reply: str + cities: list[dict] = Field(default_factory=list) + + +class NewsletterRequest(BaseModel): + email: EmailStr + source: str = "site" + + +class ContentSubmissionRequest(BaseModel): + name: str = Field(min_length=2, max_length=80) + title: str = Field(min_length=4, max_length=200) + content_type: str = "article" + url: str = "" + notes: str = "" + + +class VolunteerRequest(BaseModel): + name: str = Field(min_length=2, max_length=80) + email: EmailStr + message: str = Field(min_length=4, max_length=2000) + + +class ReportRequest(BaseModel): + target_type: str + target_id: str + reason: str = Field(min_length=4, max_length=1000) + + +class GoogleLoginRequest(BaseModel): + id_token: str + + class FeedbackRequest(BaseModel): content: str = Field(min_length=4, max_length=4000) category: str = "general" diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 0000000..1a931a7 --- /dev/null +++ b/backend/app/services/ai_service.py @@ -0,0 +1,50 @@ +"""Rule-based nomad assistant (no LLM).""" + +from __future__ import annotations + +KEYWORD_CITIES = { + "便宜": ["chiangmai", "mexico", "bali"], + "budget": ["chiangmai", "mexico", "bali"], + "快": ["tokyo", "barcelona", "lisbon"], + "网速": ["tokyo", "barcelona", "lisbon"], + "internet": ["tokyo", "barcelona", "lisbon"], + "暖": ["bali", "chiangmai", "mexico"], + "warm": ["bali", "chiangmai", "mexico"], + "冷": ["lisbon", "barcelona"], + "cool": ["lisbon", "barcelona"], + "签证": ["lisbon", "mexico"], + "visa": ["lisbon", "mexico"], + "中文": ["chiangmai", "bali"], + "chinese": ["chiangmai", "bali"], +} + + +def assistant_reply(message: str, destinations: list[dict]) -> dict: + msg = message.lower() + picks: list[str] = [] + for kw, slugs in KEYWORD_CITIES.items(): + if kw in msg: + picks.extend(slugs) + if not picks: + picks = ["chiangmai", "lisbon", "bali"] + seen: set[str] = set() + items = [] + for slug in picks: + if slug in seen: + continue + seen.add(slug) + dest = next((d for d in destinations if d.get("slug") == slug), None) + if dest: + items.append({ + "slug": slug, + "name": dest.get("name", slug), + "emoji": dest.get("emoji", "🌍"), + "reason": f"匹配你的需求:{message[:40]}", + }) + if len(items) >= 3: + break + reply = ( + f"根据你的描述,我推荐看看 {', '.join(i['name'] for i in items)}。" + " 可以用「下一站决策」做更精细的筛选,或在社区问当地细节。" + ) + return {"reply": reply, "cities": items} diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 6923767..8ffb382 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -97,6 +97,26 @@ def login_user(email: str, password: str) -> dict[str, Any] | None: return {"token": token, "user": _user_profile(user)} +def login_google(email: str, name: str) -> dict[str, Any] | None: + if email not in _users: + uid = secrets.token_hex(8) + _users[email] = { + "id": uid, + "email": email, + "password_hash": _hash_password(secrets.token_hex(16)), + "name": name or email.split("@")[0], + "avatar": "🌐", + "oauth": "google", + } + _favorites[uid] = [] + _plans[uid] = _empty_plan() + user = _users[email] + token = secrets.token_urlsafe(32) + _sessions[token] = user["id"] + _persist() + return {"token": token, "user": _user_profile(user)} + + def login_demo() -> dict[str, Any] | None: email = "demo@nomadro.com" if email not in _users: diff --git a/backend/app/services/community_store.py b/backend/app/services/community_store.py index dc4a3e5..3f6c08d 100644 --- a/backend/app/services/community_store.py +++ b/backend/app/services/community_store.py @@ -25,6 +25,7 @@ _gigs: list[dict] = [] _gig_apps: list[dict] = [] _notifications: dict[str, list[dict]] = {} # user_id -> items _feedback: list[dict] = [] +_views: dict[str, int] = {} # discussion_id -> view count _seeded = False @@ -51,6 +52,7 @@ def _persist() -> None: "gig_apps": _gig_apps, "notifications": _notifications, "feedback": _feedback, + "views": _views, "seeded": _seeded, }, ensure_ascii=False), encoding="utf-8", @@ -59,7 +61,7 @@ def _persist() -> None: def _restore() -> None: global _meetups, _discussions, _replies, _discussion_likes, _reply_likes - global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _seeded + global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _views, _seeded if not STORE_PATH.exists(): return try: @@ -76,6 +78,7 @@ def _restore() -> None: _gig_apps = data.get("gig_apps") or [] _notifications = data.get("notifications") or {} _feedback = data.get("feedback") or [] + _views = data.get("views") or {} _seeded = bool(data.get("seeded")) @@ -151,6 +154,7 @@ def _enrich_discussion(d: dict) -> dict: **d, "reply_count": len(replies), "like_count": max(base_likes, stored) if stored == 0 else base_likes + stored, + "view_count": _views.get(did, d.get("view_count", 0)), } @@ -241,10 +245,13 @@ def list_discussions(category: str | None = None) -> list[dict]: return pinned + rest -def get_discussion(discussion_id: str) -> dict | None: +def get_discussion(discussion_id: str, increment_view: bool = False) -> dict | None: _seed_if_needed() for d in _discussions: if d["id"] == discussion_id: + if increment_view: + _views[discussion_id] = _views.get(discussion_id, 0) + 1 + _persist() enriched = _enrich_discussion(d) replies = [] for r in _replies.get(discussion_id, []): @@ -343,14 +350,110 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: return {"ok": True, "message": "申请已提交"} -def add_notification(user_id: str, title: str, body: str, link: str = "") -> None: +def cancel_rsvp(meetup_id: str, user_id: str) -> dict: + _seed_if_needed() + rset = _rsvps.get(meetup_id, set()) + if user_id not in rset: + return {"ok": False, "error": "not_rsvped"} + rset.discard(user_id) + _persist() + return {"ok": True, "rsvp_count": len(rset)} + + +def create_gig(user_id: str, user_name: str, payload: dict) -> dict: + _seed_if_needed() + gid = _slugify(payload["title"]) + "-" + secrets.token_hex(2) + gig = { + "id": gid, + "title": payload["title"], + "description": payload.get("description", ""), + "budget": payload.get("budget", "面议"), + "deadline": payload.get("deadline", ""), + "tags": payload.get("tags", []), + "poster": user_name, + "poster_id": user_id, + "status": "open", + } + _gigs.insert(0, gig) + _persist() + return gig + + +def meetup_social_suggestions(meetup_id: str, limit: int = 6) -> list[dict]: + _seed_if_needed() + m = get_meetup(meetup_id) + if not m: + return [] + city = m.get("city", "") + from app.data.social_profiles import CANDIDATE_PROFILES + out = [] + for p in CANDIDATE_PROFILES: + if city and city not in (p.get("location") or ""): + continue + out.append(p) + if len(out) >= limit: + break + return out[:limit] if out else CANDIDATE_PROFILES[:limit] + + +def notification_unread_count(user_id: str) -> int: + _reload() + return sum(1 for n in _notifications.get(user_id, []) if not n.get("read")) + + +def update_notification(user_id: str, notif_id: str, action: str) -> dict | None: + _reload() + items = _notifications.get(user_id, []) + for n in items: + if n["id"] != notif_id: + continue + if action == "read": + n["read"] = True + elif action == "unread": + n["read"] = False + elif action == "archive": + n["archived"] = True + elif action == "restore": + n["archived"] = False + elif action == "pin": + n["pinned"] = True + elif action == "unpin": + n["pinned"] = False + _persist() + return n + return None + + +def list_notifications_filtered(user_id: str, include_archived: bool = False) -> list[dict]: + _reload() + items = _notifications.get(user_id, []) + if not include_archived: + items = [n for n in items if not n.get("archived")] + pinned = [n for n in items if n.get("pinned")] + rest = [n for n in items if not n.get("pinned")] + return pinned + rest + + +def search_discussions(q: str) -> list[dict]: + _seed_if_needed() + s = q.lower() + return [ + _enrich_discussion(d) for d in _discussions + if s in d["title"].lower() or s in d.get("excerpt", "").lower() + ][:20] + + +def add_notification(user_id: str, title: str, body: str, link: str = "", category: str = "general") -> None: items = _notifications.setdefault(user_id, []) items.insert(0, { "id": secrets.token_hex(6), "title": title, "body": body, "link": link, + "category": category, "read": False, + "archived": False, + "pinned": False, "created_at": _now_iso(), }) items[:] = items[:50] @@ -358,8 +461,7 @@ def add_notification(user_id: str, title: str, body: str, link: str = "") -> Non def list_notifications(user_id: str) -> list[dict]: - _reload() - return _notifications.get(user_id, []) + return list_notifications_filtered(user_id) def mark_notifications_read(user_id: str) -> None: diff --git a/backend/app/services/platform_store.py b/backend/app/services/platform_store.py new file mode 100644 index 0000000..a127367 --- /dev/null +++ b/backend/app/services/platform_store.py @@ -0,0 +1,141 @@ +"""Platform persistence: leads, submissions, volunteers, reports, newsletter.""" + +from __future__ import annotations + +import json +import secrets +from datetime import datetime, timezone +from pathlib import Path + +STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json" + +_leads: list[dict] = [] +_submissions: list[dict] = [] +_volunteers: list[dict] = [] +_reports: list[dict] = [] +_newsletter: list[dict] = [] +_notif_prefs: dict[str, dict] = {} + + +def _now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +def _persist() -> None: + STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + STORE_PATH.write_text(json.dumps({ + "leads": _leads, + "submissions": _submissions, + "volunteers": _volunteers, + "reports": _reports, + "newsletter": _newsletter, + "notif_prefs": _notif_prefs, + }, ensure_ascii=False), encoding="utf-8") + + +def _restore() -> None: + global _leads, _submissions, _volunteers, _reports, _newsletter, _notif_prefs + if not STORE_PATH.exists(): + return + try: + data = json.loads(STORE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + _leads = data.get("leads") or [] + _submissions = data.get("submissions") or [] + _volunteers = data.get("volunteers") or [] + _reports = data.get("reports") or [] + _newsletter = data.get("newsletter") or [] + _notif_prefs = data.get("notif_prefs") or {} + + +_restore() + + +def add_service_lead(service_id: str, user_id: str | None, name: str, email: str, message: str) -> dict: + item = { + "id": secrets.token_hex(6), + "service_id": service_id, + "user_id": user_id or "", + "name": name, + "email": email, + "message": message, + "created_at": _now(), + } + _leads.append(item) + _persist() + return item + + +def add_content_submission(user_id: str | None, name: str, title: str, content_type: str, url: str, notes: str) -> dict: + item = { + "id": secrets.token_hex(6), + "user_id": user_id or "", + "name": name, + "title": title, + "content_type": content_type, + "url": url, + "notes": notes, + "status": "pending", + "created_at": _now(), + } + _submissions.append(item) + _persist() + return item + + +def add_volunteer(city_slug: str, user_id: str | None, name: str, email: str, message: str) -> dict: + item = { + "id": secrets.token_hex(6), + "city_slug": city_slug, + "user_id": user_id or "", + "name": name, + "email": email, + "message": message, + "created_at": _now(), + } + _volunteers.append(item) + _persist() + return item + + +def add_report(reporter_id: str | None, target_type: str, target_id: str, reason: str) -> dict: + item = { + "id": secrets.token_hex(6), + "reporter_id": reporter_id or "", + "target_type": target_type, + "target_id": target_id, + "reason": reason, + "status": "open", + "created_at": _now(), + } + _reports.append(item) + _persist() + return item + + +def subscribe_newsletter(email: str, source: str = "site") -> dict: + for n in _newsletter: + if n["email"] == email: + return {"ok": True, "message": "已订阅"} + _newsletter.append({"email": email, "source": source, "created_at": _now()}) + _persist() + return {"ok": True, "message": "订阅成功"} + + +def get_notif_prefs(user_id: str) -> dict: + return _notif_prefs.get(user_id) or { + "email": True, + "push": False, + "match": True, + "meetup": True, + "community": True, + "marketing": False, + "quiet_hours": "", + } + + +def set_notif_prefs(user_id: str, prefs: dict) -> dict: + _notif_prefs[user_id] = {**get_notif_prefs(user_id), **prefs} + _persist() + return _notif_prefs[user_id] diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py index 13d6e21..914186d 100644 --- a/backend/app/services/social_store.py +++ b/backend/app/services/social_store.py @@ -151,6 +151,23 @@ def list_candidates( swiped_ids.add(s.get("profileId")) pool = list(CANDIDATE_PROFILES) + # Real members who completed join profile + for uid, prof in _profiles.items(): + if uid == user_id: + continue + pool.append({ + "id": f"user-{uid}", + "userId": uid, + "name": prof.get("name", "游民"), + "location": prof.get("location", "全球"), + "citySlug": prof.get("citySlug", ""), + "gender": prof.get("gender", ""), + "single": prof.get("single", ""), + "bio": prof.get("bio", ""), + "photo": prof.get("photo", "🧑‍💻"), + "tags": prof.get("tags", []), + "lookingFor": _profile_looking_for(prof), + }) if my_profile: pool = [p for p in pool if p.get("userId") != user_id] @@ -245,6 +262,26 @@ def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", matc return conv +def _find_profile(profile_id: str) -> dict | None: + for p in CANDIDATE_PROFILES: + if p["id"] == profile_id: + return p + if profile_id.startswith("user-"): + uid = profile_id[5:] + prof = _profiles.get(uid) + if prof: + return { + "id": profile_id, + "userId": uid, + "name": prof.get("name", "游民"), + "location": prof.get("location", "全球"), + "bio": prof.get("bio", ""), + "photo": prof.get("photo", "🧑‍💻"), + "tags": prof.get("tags", []), + } + return None + + def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict: _reload() if action not in LIKE_ACTIONS and action != "dislike": @@ -259,7 +296,7 @@ def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dic if s.get("userId") == user_id and s.get("profileId") == profile_id: return {"ok": False, "error": "duplicate"} - profile = next((p for p in CANDIDATE_PROFILES if p["id"] == profile_id), None) + profile = _find_profile(profile_id) if not profile: return {"ok": False, "error": "not_found"} @@ -318,10 +355,29 @@ def undo_last_swipe(user_id: str) -> dict: return {"ok": False, "error": "nothing_to_undo"} +def list_likes_received(user_id: str) -> list[dict]: + """Profiles who liked this user's profile (simulated from static pool).""" + _reload() + my_pid = f"user-{user_id}" + likers = [] + for s in _swipes: + if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS: + uid = s.get("userId") + p = _profiles.get(uid) + if p: + likers.append({**p, "id": f"user-{uid}", "userId": uid}) + return likers + + def list_likes(user_id: str) -> list[dict]: _reload() liked_ids = [s["profileId"] for s in _swipes if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS] - return [p for p in CANDIDATE_PROFILES if p["id"] in liked_ids] + out = [] + for pid in liked_ids: + p = _find_profile(pid) + if p: + out.append(p) + return out def list_mutual(user_id: str) -> list[dict]: diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py new file mode 100644 index 0000000..c867702 --- /dev/null +++ b/backend/app/services/weather_service.py @@ -0,0 +1,44 @@ +"""Open-Meteo weather for nomad cities.""" + +from __future__ import annotations + +import httpx + +from app.data.platform_data import WEATHER_CITIES + + +async def get_city_weather(slug: str) -> dict | None: + meta = WEATHER_CITIES.get(slug) + if not meta: + return None + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={meta['lat']}&longitude={meta['lng']}" + "¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code" + "&timezone=auto" + ) + try: + async with httpx.AsyncClient(timeout=8) as client: + res = await client.get(url) + res.raise_for_status() + data = res.json() + cur = data.get("current") or {} + return { + "slug": slug, + "name": meta["name"], + "temperature": cur.get("temperature_2m"), + "humidity": cur.get("relative_humidity_2m"), + "wind_speed": cur.get("wind_speed_10m"), + "weather_code": cur.get("weather_code"), + } + except (httpx.HTTPError, KeyError): + return {"slug": slug, "name": meta["name"], "temperature": None, "error": "unavailable"} + + +async def list_weather() -> list[dict]: + results = [] + for slug in WEATHER_CITIES: + w = await get_city_weather(slug) + if w: + results.append(w) + return results diff --git a/frontend/src/app/ai/page.tsx b/frontend/src/app/ai/page.tsx new file mode 100644 index 0000000..400f053 --- /dev/null +++ b/frontend/src/app/ai/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import AiAssistantClient from "@/components/AiAssistantClient"; + +export const metadata: Metadata = { + title: "AI 旅居助手 · nomadro", + description: "智能推荐下一站游民城市", +}; + +export default function AiPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/careers/page.tsx b/frontend/src/app/careers/page.tsx new file mode 100644 index 0000000..2c7be37 --- /dev/null +++ b/frontend/src/app/careers/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.careers; +export const metadata: Metadata = { title: `${page.title} · nomadro` }; +export default function CareersPage() { + return ; +} diff --git a/frontend/src/app/contact/page.tsx b/frontend/src/app/contact/page.tsx new file mode 100644 index 0000000..13bcac1 --- /dev/null +++ b/frontend/src/app/contact/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.contact; + +export const metadata: Metadata = { + title: `${page.title} · nomadro`, + description: page.subtitle || page.title, +}; + +export default function ContactPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/cookies/page.tsx b/frontend/src/app/cookies/page.tsx new file mode 100644 index 0000000..7fe4277 --- /dev/null +++ b/frontend/src/app/cookies/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.cookies; +export const metadata: Metadata = { title: `${page.title} · nomadro` }; +export default function CookiesPage() { + return ; +} diff --git a/frontend/src/app/digital/day/[id]/page.tsx b/frontend/src/app/digital/day/[id]/page.tsx new file mode 100644 index 0000000..2bc86b8 --- /dev/null +++ b/frontend/src/app/digital/day/[id]/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import SiteShell from "@/components/SiteShell"; +import Link from "next/link"; +import { api } from "@/lib/api"; + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const day = await api.getDigitalDay(id).catch(() => null); + return { title: day ? `${day.title} · nomadro` : "每日指南" }; +} + +export default async function DigitalDayPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const day = await api.getDigitalDay(id).catch(() => null); + if (!day) notFound(); + return ( + +
+ +
+ 📅 DAY {id} +

{day.title}

+

{day.body}

+
+
+
+ ); +} diff --git a/frontend/src/app/feedback/page.tsx b/frontend/src/app/feedback/page.tsx new file mode 100644 index 0000000..3f2e6fa --- /dev/null +++ b/frontend/src/app/feedback/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import FeedbackClient from "@/components/FeedbackClient"; + +export const metadata: Metadata = { + title: "反馈 · nomadro", + description: "向 nomadro 提交问题反馈与功能建议", +}; + +export default function FeedbackPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/gigs/post/page.tsx b/frontend/src/app/gigs/post/page.tsx new file mode 100644 index 0000000..a9b33a3 --- /dev/null +++ b/frontend/src/app/gigs/post/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import GigPostClient from "@/components/GigPostClient"; + +export const metadata: Metadata = { + title: "发布赏金 · nomadro", +}; + +export default function GigPostPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 880a5f7..3728a15 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -10292,6 +10292,128 @@ img { max-width: 100%; display: block; } color: var(--text-primary); } +.video-embed { + position: relative; + padding-bottom: 56.25%; + height: 0; + margin-top: 24px; + border-radius: var(--radius-lg); + overflow: hidden; +} + +.video-embed iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; +} + +.report-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + margin-top: 24px; +} + +.report-rank { list-style: none; padding: 0; } +.report-rank li { + display: grid; + grid-template-columns: 24px 1fr auto auto; + gap: 12px; + padding: 10px 0; + border-bottom: var(--border-glass); + align-items: center; +} + +.member-map-grid, .member-map-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} + +.member-map-item { + display: flex; + gap: 10px; + align-items: center; + padding: 12px; + background: var(--bg-glass); + border-radius: var(--radius-md); +} + +.weather-strip { margin-top: 32px; } +.weather-cards { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; } +.weather-card { + padding: 12px 16px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-md); + min-width: 100px; +} + +.ai-reply { + margin-top: 24px; + padding: 20px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); +} + +.ai-cities { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } + +.notif-toolbar { display: flex; gap: 8px; flex-wrap: wrap; } +.notif-pref-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: var(--border-glass); +} + +.nav-notify-badge { + position: absolute; + top: -4px; + right: -4px; + font-size: 0.65rem; + font-style: normal; + background: var(--accent-1); + color: #fff; + border-radius: 999px; + padding: 1px 5px; +} + +.nav-notify { position: relative; } + +.newsletter-strip { + margin-top: 48px; + padding: 24px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); + text-align: center; +} + +.newsletter-form { + display: flex; + gap: 10px; + justify-content: center; + margin-top: 12px; + flex-wrap: wrap; +} + +.newsletter-form input { + min-width: 220px; + padding: 10px 14px; + border-radius: var(--radius-md); + border: var(--border-glass); + background: var(--bg-glass); + color: var(--text-primary); +} + +@media (max-width: 768px) { + .report-grid { grid-template-columns: 1fr; } +} + .dating-likes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); diff --git a/frontend/src/app/guidelines/page.tsx b/frontend/src/app/guidelines/page.tsx new file mode 100644 index 0000000..31664d9 --- /dev/null +++ b/frontend/src/app/guidelines/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.guidelines; + +export const metadata: Metadata = { + title: `${page.title} · nomadro`, + description: "nomadro 社区行为准则", +}; + +export default function GuidelinesPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/help/page.tsx b/frontend/src/app/help/page.tsx new file mode 100644 index 0000000..f25a08c --- /dev/null +++ b/frontend/src/app/help/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.help; + +export const metadata: Metadata = { + title: `${page.title} · nomadro`, + description: "nomadro 帮助中心与常见问题", +}; + +export default function HelpPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/map/page.tsx b/frontend/src/app/map/page.tsx new file mode 100644 index 0000000..bb975c1 --- /dev/null +++ b/frontend/src/app/map/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import MemberMapClient from "@/components/MemberMapClient"; + +export const metadata: Metadata = { + title: "游民地图 · nomadro", + description: "全球游民分布与目的地实时天气", +}; + +export default function MapPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/press/page.tsx b/frontend/src/app/press/page.tsx new file mode 100644 index 0000000..847a991 --- /dev/null +++ b/frontend/src/app/press/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.press; +export const metadata: Metadata = { title: `${page.title} · nomadro` }; +export default function PressPage() { + return ; +} diff --git a/frontend/src/app/report/page.tsx b/frontend/src/app/report/page.tsx new file mode 100644 index 0000000..32deeca --- /dev/null +++ b/frontend/src/app/report/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import ReportClient from "@/components/ReportClient"; + +export const metadata: Metadata = { + title: "数据报告 · nomadro", + description: "游民城市排名与会员分布", +}; + +export default function ReportPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/safety/page.tsx b/frontend/src/app/safety/page.tsx new file mode 100644 index 0000000..42359fb --- /dev/null +++ b/frontend/src/app/safety/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.safety; + +export const metadata: Metadata = { + title: `${page.title} · nomadro`, + description: "nomadro 社区安全指南", +}; + +export default function SafetyPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/services/page.tsx b/frontend/src/app/services/page.tsx new file mode 100644 index 0000000..cc893e2 --- /dev/null +++ b/frontend/src/app/services/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import ServicesClient from "@/components/ServicesClient"; +import { api } from "@/lib/api"; + +export const metadata: Metadata = { + title: "游民服务 · nomadro", + description: "签证咨询、税务评估、落地陪跑等认证服务", +}; + +export default async function ServicesPage() { + const services = await api.getServices().catch(() => []); + return ( + + + + ); +} diff --git a/frontend/src/app/settings/notifications/page.tsx b/frontend/src/app/settings/notifications/page.tsx new file mode 100644 index 0000000..b0f59b3 --- /dev/null +++ b/frontend/src/app/settings/notifications/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import NotificationSettingsClient from "@/components/NotificationSettingsClient"; + +export const metadata: Metadata = { + title: "通知设置 · nomadro", +}; + +export default function NotificationSettingsPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index 9e0696c..000f6f6 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -23,7 +23,19 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${SITE}/community/new`, lastModified: now, changeFrequency: "weekly", priority: 0.75 }, { url: `${SITE}/meetups/host`, lastModified: now, changeFrequency: "weekly", priority: 0.75 }, { url: `${SITE}/dating/likes`, lastModified: now, changeFrequency: "weekly", priority: 0.7 }, - { url: `${SITE}/digital`, lastModified: now, changeFrequency: "weekly", priority: 0.85 }, + { url: `${SITE}/ai`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, + { url: `${SITE}/videos`, lastModified: now, changeFrequency: "weekly", priority: 0.75 }, + { url: `${SITE}/services`, lastModified: now, changeFrequency: "weekly", priority: 0.75 }, + { url: `${SITE}/map`, lastModified: now, changeFrequency: "weekly", priority: 0.7 }, + { url: `${SITE}/report`, lastModified: now, changeFrequency: "weekly", priority: 0.65 }, + { url: `${SITE}/feedback`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, + { url: `${SITE}/submit`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, + { url: `${SITE}/gigs/post`, lastModified: now, changeFrequency: "weekly", priority: 0.7 }, + { url: `${SITE}/settings/notifications`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, + { url: `${SITE}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.4 }, + { url: `${SITE}/help`, lastModified: now, changeFrequency: "yearly", priority: 0.4 }, + { url: `${SITE}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }, + { url: `${SITE}/join`, lastModified: now, changeFrequency: "monthly", priority: 0.75 }, { url: `${SITE}/digital/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 }, { url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 }, { url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 }, diff --git a/frontend/src/app/submit/page.tsx b/frontend/src/app/submit/page.tsx new file mode 100644 index 0000000..a332524 --- /dev/null +++ b/frontend/src/app/submit/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import ContentSubmitClient from "@/components/ContentSubmitClient"; + +export const metadata: Metadata = { + title: "内容投稿 · nomadro", +}; + +export default function SubmitPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/support/page.tsx b/frontend/src/app/support/page.tsx new file mode 100644 index 0000000..889475c --- /dev/null +++ b/frontend/src/app/support/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.support; +export const metadata: Metadata = { title: `${page.title} · nomadro` }; +export default function SupportPage() { + return ; +} diff --git a/frontend/src/app/terms/page.tsx b/frontend/src/app/terms/page.tsx new file mode 100644 index 0000000..eef8f4c --- /dev/null +++ b/frontend/src/app/terms/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import StaticPage from "@/components/StaticPage"; +import { STATIC_PAGES } from "@/lib/staticPages"; + +const page = STATIC_PAGES.terms; + +export const metadata: Metadata = { + title: `${page.title} · nomadro`, + description: "nomadro 服务条款", +}; + +export default function TermsPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/videos/[slug]/page.tsx b/frontend/src/app/videos/[slug]/page.tsx new file mode 100644 index 0000000..c6618a7 --- /dev/null +++ b/frontend/src/app/videos/[slug]/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import SiteShell from "@/components/SiteShell"; +import VideoDetailClient from "@/components/VideoDetailClient"; +import { api } from "@/lib/api"; + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params; + const video = await api.getVideo(slug).catch(() => null); + return { title: video ? `${video.title} · nomadro` : "视频 · nomadro" }; +} + +export default async function VideoDetailPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const video = await api.getVideo(slug).catch(() => null); + if (!video) notFound(); + return ( + + + + ); +} diff --git a/frontend/src/app/videos/page.tsx b/frontend/src/app/videos/page.tsx new file mode 100644 index 0000000..21f429f --- /dev/null +++ b/frontend/src/app/videos/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import SiteShell from "@/components/SiteShell"; +import VideosClient from "@/components/VideosClient"; +import { api } from "@/lib/api"; + +export const metadata: Metadata = { + title: "游民访谈 · nomadro", + description: "数字游民真实旅居访谈与实录", +}; + +export default async function VideosPage() { + const videos = await api.getVideos().catch(() => []); + return ( + + + + ); +} diff --git a/frontend/src/components/AiAssistantClient.tsx b/frontend/src/components/AiAssistantClient.tsx new file mode 100644 index 0000000..f4062c3 --- /dev/null +++ b/frontend/src/components/AiAssistantClient.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { api } from "@/lib/api"; +import { useI18n } from "@/lib/i18n"; + +export default function AiAssistantClient() { + const { t } = useI18n(); + const [message, setMessage] = useState(""); + const [reply, setReply] = useState(""); + const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]); + const [loading, setLoading] = useState(false); + + const ask = async () => { + if (!message.trim()) return; + setLoading(true); + try { + const res = await api.askAssistant(message.trim()); + setReply(res.reply); + setCities(res.cities || []); + } catch { + setReply(t.ai.fail); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ {t.ai.tag} +

{t.ai.title}

+

{t.ai.subtitle}

+
+
+