diff --git a/backend/app/data/city_details_data.py b/backend/app/data/city_details_data.py index 6ddf15b..113cbaa 100644 --- a/backend/app/data/city_details_data.py +++ b/backend/app/data/city_details_data.py @@ -187,6 +187,71 @@ EXTRA_ROUTES: list[dict[str, Any]] = [ "status": "published", "emoji": "🚄", }, + { + "slug": "tax-base-loop", + "title": "税务友好试住线", + "titleEn": "Tax Base Loop", + "citySlugs": ["tbilisi", "dubai"], + "durationDays": 75, + "budget": 22000, + "description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。", + "descriptionEn": "Tbilisi base trial plus Dubai hub month.", + "stops": ["第比利斯 45 天", "迪拜 30 天"], + "status": "published", + "emoji": "🧾", + }, + { + "slug": "latam-spring-trail", + "title": "拉美春城双城", + "titleEn": "LATAM Spring Trail", + "citySlugs": ["medellin", "mexico"], + "durationDays": 60, + "budget": 15000, + "description": "麦德林 → 墨西哥城,北美时区远程友好。", + "descriptionEn": "Medellín to Mexico City for NA timezones.", + "stops": ["麦德林 30 天", "墨西哥城 30 天"], + "status": "published", + "emoji": "🌺", + }, + { + "slug": "create-slow-china", + "title": "国内慢创作线", + "titleEn": "China Slow Create", + "citySlugs": ["dali"], + "durationDays": 45, + "budget": 9000, + "description": "大理深度创作,适合内容创作者与独立开发者。", + "descriptionEn": "Dali deep create stay.", + "stops": ["大理 45 天"], + "status": "published", + "emoji": "📷", + }, + { + "slug": "euro-oss-sprint", + "title": "欧洲开源冲刺线", + "titleEn": "Euro OSS Sprint", + "citySlugs": ["berlin", "lisbon"], + "durationDays": 50, + "budget": 32000, + "description": "柏林技术社群 + 里斯本阳光回调。", + "descriptionEn": "Berlin tech then Lisbon sun.", + "stops": ["柏林 25 天", "里斯本 25 天"], + "status": "published", + "emoji": "🖥️", + }, + { + "slug": "east-asia-net", + "title": "东亚网速双城", + "titleEn": "East Asia Net Duo", + "citySlugs": ["seoul", "tokyo"], + "durationDays": 35, + "budget": 30000, + "description": "首尔与东京高强度协作月,适合产品迭代。", + "descriptionEn": "Seoul and Tokyo sprint months.", + "stops": ["首尔 18 天", "东京 17 天"], + "status": "published", + "emoji": "📶", + }, ] @@ -234,4 +299,45 @@ CONTENT_ITEMS: list[dict[str, Any]] = [ "status": "published", "authorName": "nomadro", }, + { + "slug": "medellin-trial", + "type": "guide", + "title": "麦德林试住手册", + "titleEn": "Medellín Trial Guide", + "subtitle": "安全区、西语与北美时区", + "description": "30 天试住清单与预算模板。", + "targetUrl": "/destinations/medellin", + "ctaLabel": "看城市", + "citySlugs": ["medellin"], + "sortOrder": 4, + "status": "published", + "authorName": "Sofia", + }, + { + "slug": "tbilisi-tax-note", + "type": "guide", + "title": "第比利斯税务笔记", + "titleEn": "Tbilisi Tax Notes", + "subtitle": "远程签证与开户提醒", + "description": "非正式法律意见,帮助你先做试住决策。", + "targetUrl": "/destinations/tbilisi", + "ctaLabel": "打开笔记", + "citySlugs": ["tbilisi"], + "sortOrder": 5, + "status": "published", + "authorName": "Devon", + }, + { + "slug": "first-month-video", + "type": "video", + "title": "旅居第一月稳定输出", + "subtitle": "SIM、短租、社群五步法", + "description": "新手最容易踩坑的第一周怎么过。", + "targetUrl": "/videos/first-month", + "ctaLabel": "看视频", + "citySlugs": ["chiangmai", "lisbon", "bali"], + "sortOrder": 6, + "status": "published", + "authorName": "nomadro", + }, ] diff --git a/backend/app/data/community_data.py b/backend/app/data/community_data.py index 37604e3..e6f96b1 100644 --- a/backend/app/data/community_data.py +++ b/backend/app/data/community_data.py @@ -26,7 +26,7 @@ MEETUPS = [ "id": "chiangmai-cowork", "title": "清迈联合办公下午茶", "city": "清迈", - "destination_slug": "chiang-mai", + "destination_slug": "chiangmai", "emoji": "☕", "date": "2026-09-18", "time": "15:00", @@ -96,6 +96,133 @@ MEETUPS = [ "tags": ["税务", "日本", "干货"], "is_upcoming": True, }, + { + "id": "berlin-opensource", + "title": "柏林开源与 DevOps 夜谈", + "city": "柏林", + "destination_slug": "berlin", + "emoji": "🖥️", + "date": "2026-09-28", + "time": "19:00", + "venue": "Kreuzberg Co-working", + "description": "分享自托管、CI 与远程团队协作实践,会后可继续社交。", + "mode": "hybrid", + "access_level": "public", + "mirotalkRoom": "nomadro-berlin-oss", + "rsvp_count": 22, + "max_attendees": 40, + "organizer": "Berlin Remote", + "tags": ["开源", "技术", "欧洲"], + "is_upcoming": True, + }, + { + "id": "dali-create-walk", + "title": "大理洱海创作散步", + "city": "大理", + "destination_slug": "dali", + "emoji": "📷", + "date": "2026-10-02", + "time": "16:00", + "venue": "才村码头集合", + "description": "边走边拍,分享内容选题与变现路径,适合创作者与慢旅居者。", + "mode": "offline", + "access_level": "public", + "rsvp_count": 8, + "max_attendees": 18, + "organizer": "大理创作者局", + "tags": ["内容", "户外", "国内"], + "is_upcoming": True, + }, + { + "id": "medellin-spanish", + "title": "麦德林西语咖啡局", + "city": "麦德林", + "destination_slug": "medellin", + "emoji": "☕", + "date": "2026-10-08", + "time": "10:30", + "venue": "Laureles 咖啡馆", + "description": "初级西语练习 + 安全区与租房情报交换,新手友好。", + "mode": "offline", + "access_level": "public", + "rsvp_count": 16, + "max_attendees": 20, + "organizer": "Medellín Nomads", + "tags": ["语言", "拉美", "社交"], + "is_upcoming": True, + }, + { + "id": "seoul-night-office", + "title": "首尔深夜联合办公", + "city": "首尔", + "destination_slug": "seoul", + "emoji": "🌃", + "date": "2026-10-15", + "time": "21:00", + "venue": "江南共享办公", + "description": "适合东亚时区冲刺交付的游民,自带电脑即可。", + "mode": "offline", + "access_level": "public", + "rsvp_count": 12, + "max_attendees": 25, + "organizer": "Seoul Workation", + "tags": ["办公", "韩国", "效率"], + "is_upcoming": True, + }, + { + "id": "dubai-hub-brunch", + "title": "迪拜枢纽早午餐", + "city": "迪拜", + "destination_slug": "dubai", + "emoji": "🥂", + "date": "2026-10-18", + "time": "11:00", + "venue": "Downtown 咖啡厅", + "description": "中转游民见面:签证、航班与下一站城市选择。", + "mode": "offline", + "access_level": "public", + "rsvp_count": 10, + "max_attendees": 16, + "organizer": "Dubai Hub Crew", + "tags": ["中东", "社交", "跳板"], + "is_upcoming": True, + }, + { + "id": "tbilisi-tax-cafe", + "title": "第比利斯税务咖啡聊", + "city": "第比利斯", + "destination_slug": "tbilisi", + "emoji": "🍷", + "date": "2026-10-22", + "time": "15:00", + "venue": "Vake 区咖啡馆", + "description": "交流远程签证与本地公司实务(非正式法律意见),会后可徒步旧城。", + "mode": "offline", + "access_level": "members", + "rsvp_count": 19, + "max_attendees": 22, + "organizer": "Tbilisi Base", + "tags": ["税务", "欧洲", "干货"], + "is_upcoming": True, + }, + { + "id": "mexico-food-walk", + "title": "墨西哥城街头美食漫步", + "city": "墨西哥城", + "destination_slug": "mexico", + "emoji": "🌮", + "date": "2026-10-26", + "time": "17:30", + "venue": "Roma Norte 集合", + "description": "边吃边聊北美时区接单与安全出行经验。", + "mode": "offline", + "access_level": "public", + "rsvp_count": 13, + "max_attendees": 18, + "organizer": "CDMX Nomads", + "tags": ["美食", "拉美", "社交"], + "is_upcoming": True, + }, ] DISCUSSIONS = [ @@ -164,6 +291,90 @@ DISCUSSIONS = [ "created_at": "2026-08-28", "tags": ["活动", "投票"], }, + { + "id": "medellin-safety", + "title": "麦德林哪些街区适合第一次长住?", + "excerpt": "Laureles / Poblado / Envigado 的网速、噪音和夜间出行体验求分享。", + "author": "Sofia", + "author_emoji": "🌺", + "category": "住宿", + "reply_count": 17, + "like_count": 41, + "created_at": "2026-08-30", + "tags": ["麦德林", "安全"], + }, + { + "id": "tbilisi-bank", + "title": "第比利斯开户与收美元的实操经验", + "excerpt": "需要本地号码吗?Wise 能不能直接用?求最新流程。", + "author": "Devon", + "author_emoji": "🍷", + "category": "财务", + "reply_count": 14, + "like_count": 36, + "created_at": "2026-09-01", + "tags": ["格鲁吉亚", "银行"], + }, + { + "id": "berlin-winter", + "title": "柏林冬天怎么保持产出和社交?", + "excerpt": "日照短、天气冷,有没有室内运动 + meetup 推荐。", + "author": "Ken", + "author_emoji": "🎨", + "category": "生活方式", + "reply_count": 9, + "like_count": 22, + "created_at": "2026-09-02", + "tags": ["柏林", "健康"], + }, + { + "id": "dali-wifi", + "title": "大理合租找房:网速坑位合集", + "excerpt": "哪些区域光纤稳定?有没有房东靠谱名单。", + "author": "Lina", + "author_emoji": "📷", + "category": "住宿", + "reply_count": 21, + "like_count": 48, + "created_at": "2026-09-03", + "tags": ["大理", "网速"], + }, + { + "id": "dubai-summer", + "title": "迪拜夏天还能正常远程办公吗?", + "excerpt": "空调联合办公、作息调整和预算增加幅度求真实数据。", + "author": "Omar", + "author_emoji": "🏙️", + "category": "生活方式", + "reply_count": 8, + "like_count": 19, + "created_at": "2026-09-04", + "tags": ["迪拜", "气候"], + }, + { + "id": "seoul-visa", + "title": "韩国 Workation 签证材料清单核对", + "excerpt": "收入证明、保险、行程……有人最近过签吗?", + "author": "Yuki", + "author_emoji": "🇰🇷", + "category": "签证", + "reply_count": 12, + "like_count": 33, + "created_at": "2026-09-05", + "tags": ["韩国", "签证"], + }, + { + "id": "cowork-month-pass", + "title": "各城市联合办公月票性价比对比", + "excerpt": "清迈 / 里斯本 / 柏林 / 麦德林,欢迎贴价目与座位体验。", + "author": "Alex", + "author_emoji": "🚀", + "category": "远程工作", + "reply_count": 26, + "like_count": 61, + "created_at": "2026-09-06", + "tags": ["办公", "成本"], + }, ] GIGS = [ @@ -176,6 +387,8 @@ GIGS = [ "tags": ["设计", "远程"], "poster": "nomadro", "status": "open", + "category": "设计", + "location": "远程", }, { "id": "meetup-host", @@ -186,6 +399,8 @@ GIGS = [ "tags": ["活动", "深圳"], "poster": "深圳湾区主理人", "status": "open", + "category": "活动", + "location": "深圳", }, { "id": "content-translate", @@ -196,6 +411,116 @@ GIGS = [ "tags": ["翻译", "内容"], "poster": "nomadro", "status": "open", + "category": "内容", + "location": "远程", + }, + { + "id": "landing-page", + "title": "城市落地页前端实现", + "description": "Next.js + Tailwind,按 Figma 还原清迈/里斯本两套落地页。", + "budget": "¥3500", + "deadline": "2026-10-10", + "tags": ["前端", "Next.js"], + "poster": "Marco", + "status": "open", + "category": "开发", + "location": "远程", + }, + { + "id": "video-edit", + "title": "游民访谈短视频剪辑", + "description": "4 条 60–90 秒竖版视频,含字幕与封面。", + "budget": "¥1500", + "deadline": "2026-10-12", + "tags": ["视频", "剪辑"], + "poster": "Lina", + "status": "open", + "category": "内容", + "location": "远程", + }, + { + "id": "seo-audit", + "title": "目的地内容 SEO 审计", + "description": "审计 10 篇城市指南关键词与内链结构,输出改进清单。", + "budget": "¥900", + "deadline": "2026-10-08", + "tags": ["SEO", "内容"], + "poster": "nomadro", + "status": "open", + "category": "运营", + "location": "远程", + }, + { + "id": "photo-pack", + "title": "大理旅居照片素材包", + "description": "需 30 张可商用风格照片(咖啡馆、洱海、合租场景)。", + "budget": "¥600", + "deadline": "2026-10-05", + "tags": ["摄影", "大理"], + "poster": "Lina", + "status": "open", + "category": "设计", + "location": "大理", + }, + { + "id": "tax-checklist", + "title": "格鲁吉亚税务清单校对", + "description": "把英文政策摘要整理成中文 checklist(非法律意见)。", + "budget": "¥700", + "deadline": "2026-10-18", + "tags": ["税务", "文档"], + "poster": "Devon", + "status": "open", + "category": "内容", + "location": "远程", + }, + { + "id": "meetup-photo", + "title": "麦德林活动跟拍", + "description": "一场线下咖啡局跟拍 + 当天出 15 张精修。", + "budget": "$80", + "deadline": "2026-10-08", + "tags": ["摄影", "活动"], + "poster": "Sofia", + "status": "open", + "category": "活动", + "location": "麦德林", + }, + { + "id": "chatbot-faq", + "title": "签证 FAQ 聊天机器人语料", + "description": "整理 50 条问答并标注意图,供客服机器人训练。", + "budget": "¥1100", + "deadline": "2026-10-20", + "tags": ["AI", "内容"], + "poster": "Alex", + "status": "open", + "category": "开发", + "location": "远程", + }, + { + "id": "ux-test", + "title": "下一站推荐工具可用性测试", + "description": "招募 5 名游民完成任务并录制反馈(远程)。", + "budget": "¥80/人", + "deadline": "2026-10-15", + "tags": ["UX", "研究"], + "poster": "Sara", + "status": "open", + "category": "产品", + "location": "远程", + }, + { + "id": "community-mod", + "title": "周末社区值班版主", + "description": "处理讨论区举报与欢迎新人,需中英双语。", + "budget": "¥300/周末", + "deadline": "2026-10-30", + "tags": ["社区", "兼职"], + "poster": "nomadro", + "status": "open", + "category": "运营", + "location": "远程", }, ] @@ -217,6 +542,14 @@ DISCUSSION_REPLIES = { "created_at": "2026-08-21", "like_count": 8, }, + { + "id": "r3", + "author": "Yuki", + "author_emoji": "🇮🇩", + "content": "巴厘岛短期用 B211A 足够,长期再看第二家园成本是否划算。", + "created_at": "2026-08-22", + "like_count": 6, + }, ], "async-remote": [ { @@ -227,6 +560,64 @@ DISCUSSION_REPLIES = { "created_at": "2026-08-23", "like_count": 9, }, + { + "id": "r2", + "author": "Alex", + "author_emoji": "🚀", + "content": "模板三要素:昨天完成 / 今天计划 / 卡点,别写散文。", + "created_at": "2026-08-23", + "like_count": 11, + }, + ], + "coliving-tips": [ + { + "id": "r1", + "author": "小林", + "author_emoji": "🧳", + "content": "一定要问清押金退还条件和网速实测截图,别只看宣传。", + "created_at": "2026-08-26", + "like_count": 14, + }, + ], + "medellin-safety": [ + { + "id": "r1", + "author": "Alex", + "author_emoji": "🌮", + "content": "第一次建议 Laureles,生活成本与便利平衡;夜间少拿手机逛街。", + "created_at": "2026-08-31", + "like_count": 10, + }, + ], + "tbilisi-bank": [ + { + "id": "r1", + "author": "Omar", + "author_emoji": "🏙️", + "content": "先办本地 SIM 再去银行,材料准备护照+地址证明更顺利。", + "created_at": "2026-09-01", + "like_count": 7, + }, + ], + "dali-wifi": [ + { + "id": "r1", + "author": "阿静", + "author_emoji": "🏡", + "content": "才村/喜洲合租差别大,入住前用手机热点对照测速最靠谱。", + "created_at": "2026-09-03", + "like_count": 9, + }, + ], + "cowork-month-pass": [ + { + "id": "r1", + "author": "Marco", + "author_emoji": "💻", + "content": "里斯本月票大约 €150–250;清迈很多空间 ¥600–1000 就够用。", + "created_at": "2026-09-06", + "like_count": 15, + }, ], } @@ -271,4 +662,44 @@ NOMAD_ROUTES = [ "stops": ["tokyo", "chiangmai"], "emoji": "🚄", }, + { + "id": "tax-base-loop", + "slug": "tax-base-loop", + "title": "税务友好试住线", + "duration_days": 75, + "budget": 22000, + "description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。", + "stops": ["tbilisi", "dubai"], + "emoji": "🧾", + }, + { + "id": "latam-spring", + "slug": "latam-spring-trail", + "title": "拉美春城双城", + "duration_days": 60, + "budget": 15000, + "description": "麦德林 → 墨西哥城,北美时区远程友好。", + "stops": ["medellin", "mexico"], + "emoji": "🌺", + }, + { + "id": "create-slow", + "slug": "create-slow-china", + "title": "国内慢创作线", + "duration_days": 45, + "budget": 9000, + "description": "大理深度创作 + 短途补给,适合内容创作者。", + "stops": ["dali"], + "emoji": "📷", + }, + { + "id": "euro-oss", + "slug": "euro-oss-sprint", + "title": "欧洲开源冲刺线", + "duration_days": 50, + "budget": 32000, + "description": "柏林技术社群 + 里斯本阳光回调。", + "stops": ["berlin", "lisbon"], + "emoji": "🖥️", + }, ] diff --git a/backend/app/data/digital_content.py b/backend/app/data/digital_content.py index 45930bf..7b37c8e 100644 --- a/backend/app/data/digital_content.py +++ b/backend/app/data/digital_content.py @@ -117,4 +117,94 @@ JOBS = [ "tags": ["社区", "写作", "游民"], "url": "https://nomadro.com", }, + { + "id": "j4", + "title": "Product Designer", + "company": "Atlantic Remote", + "location": "欧盟时区 ±3h", + "type": "全职", + "salary": "€55k–75k", + "tags": ["Figma", "B2B", "远程"], + "url": "https://nomadro.com", + }, + { + "id": "j5", + "title": "Growth Marketer", + "company": "Hub Ventures", + "location": "中东/欧洲友好", + "type": "合同", + "salary": "$4k–6k/月", + "tags": ["SEO", "内容", "增长"], + "url": "https://nomadro.com", + }, + { + "id": "j6", + "title": "Backend Engineer (Python)", + "company": "Latam Cloud", + "location": "北美时区", + "type": "全职", + "salary": "$90k–130k", + "tags": ["Python", "FastAPI", "Postgres"], + "url": "https://nomadro.com", + }, + { + "id": "j7", + "title": "Community Manager", + "company": "nomadro", + "location": "全球远程", + "type": "兼职", + "salary": "¥8k–12k/月", + "tags": ["社区", "活动", "中英"], + "url": "https://nomadro.com", + }, + { + "id": "j8", + "title": "Mobile Engineer (iOS/Android)", + "company": "Seoul Soft", + "location": "东亚时区", + "type": "全职", + "salary": "$70k–100k", + "tags": ["Flutter", "移动端"], + "url": "https://nomadro.com", + }, + { + "id": "j9", + "title": "Technical Writer", + "company": "Docs Anywhere", + "location": "异步优先", + "type": "合同", + "salary": "$40–70/h", + "tags": ["文档", "开发者关系"], + "url": "https://nomadro.com", + }, + { + "id": "j10", + "title": "Data Analyst", + "company": "Wander Metrics", + "location": "全球远程", + "type": "全职", + "salary": "$65k–95k", + "tags": ["SQL", "看板", "增长"], + "url": "https://nomadro.com", + }, + { + "id": "j11", + "title": "Customer Success(签证顾问支持)", + "company": "nomadro", + "location": "中国时区友好", + "type": "兼职", + "salary": "¥6k–10k/月", + "tags": ["客服", "签证", "中文"], + "url": "https://nomadro.com", + }, + { + "id": "j12", + "title": "Full-stack Engineer", + "company": "Indie Nomad Tools", + "location": "全球远程", + "type": "全职", + "salary": "$85k–115k", + "tags": ["Next.js", "Python", "独立产品"], + "url": "https://nomadro.com", + }, ] diff --git a/backend/app/data/mock_data.py b/backend/app/data/mock_data.py index 1f66f07..9884b4a 100644 --- a/backend/app/data/mock_data.py +++ b/backend/app/data/mock_data.py @@ -43,6 +43,48 @@ DESTINATIONS = [ "nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"], "map_x": 820, "map_y": 210, }, + { + "id": "7", "slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨", + "tag": "欧洲 · 创意之都", "description": "开放的创业与开源氛围,租金相对西欧友好,夜生活与艺术场景极具吸引力。", + "region": "europe", "cost": 9800, "speed": 140, "temperature": 12, "rating": 8.9, "hue": 250, + "nomads_count": "7,200+", "highlights": ["🎨 艺术与街头文化", "💻 创业生态活跃", "🚲 骑行友好", "🍺 社交场景丰富"], + "map_x": 500, "map_y": 160, + }, + { + "id": "8", "slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️", + "tag": "国内 · 慢生活", "description": "苍山洱海之间的内容创作者聚集地,生活节奏慢、社交松弛,适合深度创作与试住。", + "region": "asia", "cost": 4200, "speed": 80, "temperature": 18, "rating": 8.7, "hue": 190, + "nomads_count": "3,600+", "highlights": ["📷 内容创作友好", "🏞️ 自然风光", "🍵 咖啡馆密集", "🏡 合租成本低"], + "map_x": 730, "map_y": 220, + }, + { + "id": "9", "slug": "seoul", "name": "首尔", "country": "韩国", "emoji": "🏙️", + "tag": "亚洲 · 潮流都市", "description": "高速网络与深夜城市活力并存,适合对接东亚市场与产品迭代。", + "region": "asia", "cost": 11000, "speed": 220, "temperature": 14, "rating": 8.5, "hue": 330, + "nomads_count": "3,200+", "highlights": ["📶 全球顶尖网速", "🍜 美食与便利店文化", "🛍️ 潮流消费", "🚇 地铁覆盖极强"], + "map_x": 800, "map_y": 200, + }, + { + "id": "10", "slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺", + "tag": "拉美 · 春城", "description": "四季如春、生活成本友好,英语游民社区成熟,适合北美时区远程工作。", + "region": "latam", "cost": 5200, "speed": 90, "temperature": 23, "rating": 9.0, "hue": 140, + "nomads_count": "6,400+", "highlights": ["🌤️ 四季如春", "💰 高性价比", "🕐 北美时区", "🤝 社区活动密集"], + "map_x": 260, "map_y": 300, + }, + { + "id": "11", "slug": "dubai", "name": "迪拜", "country": "阿联酋", "emoji": "🏙️", + "tag": "中东 · 枢纽都市", "description": "免税与签证便利吸引远程高收入人群,国际航班枢纽,适合中东/欧亚跳板。", + "region": "asia", "cost": 14000, "speed": 180, "temperature": 32, "rating": 8.4, "hue": 40, + "nomads_count": "4,800+", "highlights": ["🧾 税务友好", "✈️ 航线枢纽", "🏗️ 现代基础设施", "🌐 多元国际社区"], + "map_x": 600, "map_y": 240, + }, + { + "id": "12", "slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷", + "tag": "欧亚 · 税务友好", "description": "1% 小企业税与远程友好签证,旧城氛围浓厚,东欧游民新基地。", + "region": "europe", "cost": 4800, "speed": 70, "temperature": 16, "rating": 8.8, "hue": 15, + "nomads_count": "5,100+", "highlights": ["🧾 1% 小企业税", "🏰 旧城与温泉", "🍷 葡萄酒文化", "💰 低生活成本"], + "map_x": 560, "map_y": 190, + }, ] VISAS = [ @@ -58,6 +100,18 @@ VISAS = [ "duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"}, {"id": "6", "country": "爱沙尼亚", "flag": "🇪🇪", "name": "爱沙尼亚 DNV", "badge": "🚀 先锋", "badge_type": "pioneer", "duration": "1年", "income_req": "€3,504/月", "approval_time": "2-4 周", "extra": "💻 全球首个数字游民签证", "difficulty": 40, "difficulty_label": "中等"}, + {"id": "7", "country": "格鲁吉亚", "flag": "🇬🇪", "name": "格鲁吉亚 Remotely from Georgia", "badge": "⭐ 推荐", "badge_type": "easy", + "duration": "1年", "income_req": "$2,000/月", "approval_time": "约 10 天", "extra": "🧾 可搭配小企业 1% 税", "difficulty": 28, "difficulty_label": "简单"}, + {"id": "8", "country": "阿联酋", "flag": "🇦🇪", "name": "迪拜 Virtual Working Programme", "badge": "🔥 热门", "badge_type": "hot", + "duration": "1年", "income_req": "$3,500/月", "approval_time": "2-4 周", "extra": "✈️ 中东航线枢纽", "difficulty": 42, "difficulty_label": "中等"}, + {"id": "9", "country": "哥伦比亚", "flag": "🇨🇴", "name": "哥伦比亚 V Visa (Digital Nomad)", "badge": "💰 低成本", "badge_type": "budget", + "duration": "最长 2 年", "income_req": "约 3× 最低工资", "approval_time": "2-6 周", "extra": "🌺 适合麦德林春城", "difficulty": 32, "difficulty_label": "简单"}, + {"id": "10", "country": "韩国", "flag": "🇰🇷", "name": "韩国 Workation Visa (C-3-8)", "badge": "🆕 新政策", "badge_type": "new", + "duration": "最长 2 年", "income_req": "约 $65,000/年", "approval_time": "1-2 个月", "extra": "🏙️ 对接首尔与济州", "difficulty": 55, "difficulty_label": "中等"}, + {"id": "11", "country": "德国", "flag": "🇩🇪", "name": "德国 Freelance / Self-employment", "badge": "🚀 先锋", "badge_type": "pioneer", + "duration": "1-3年", "income_req": "需商业计划与客户证明", "approval_time": "2-4 个月", "extra": "🎨 适合柏林自由职业", "difficulty": 65, "difficulty_label": "较难"}, + {"id": "12", "country": "泰国", "flag": "🇹🇭", "name": "泰国 DTV 签证", "badge": "⭐ 推荐", "badge_type": "easy", + "duration": "最长 5 年(多次入境)", "income_req": "约 50 万泰铢存款证明", "approval_time": "2-6 周", "extra": "🤝 远程工作友好", "difficulty": 38, "difficulty_label": "中等"}, ] FAQS = [ @@ -79,6 +133,11 @@ TESTIMONIALS = [ {"id": "1", "avatar": "👩‍💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5}, {"id": "2", "avatar": "👨‍🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5}, {"id": "3", "avatar": "🧑‍💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "rating": 5}, + {"id": "4", "avatar": "👩‍🎨", "content": "麦德林四季如春,英语社区很活跃。我用北美时区接单,下午还能去公园运动,效率反而更高。", "author": "Sofia", "role": "自由职业设计 · 麦德林 🇨🇴", "rating": 5}, + {"id": "5", "avatar": "👨‍💻", "content": "第比利斯生活成本低,还能开本地公司走 1% 税。旧城咖啡馆写代码,周末去卡兹别吉徒步,节奏很舒服。", "author": "Devon", "role": "DevOps · 第比利斯 🇬🇪", "rating": 5}, + {"id": "6", "avatar": "📷", "content": "大理适合慢创作。合租便宜、风景治愈,但要主动找同频伙伴,别只待在民宿里。", "author": "Lina", "role": "内容创作者 · 大理 🇨🇳", "rating": 4}, + {"id": "7", "avatar": "🧑‍🚀", "content": "柏林创业氛围强,开源 meetup 特别多。冬天冷、德语门槛真实存在,但找到圈子后很值得。", "author": "Ken", "role": "数据分析 · 柏林 🇩🇪", "rating": 4}, + {"id": "8", "avatar": "🏙️", "content": "迪拜当跳板很香:航班方便、签证路径清晰。夏天太热,建议把深度工作时间放在室内联合办公。", "author": "Omar", "role": "增长顾问 · 迪拜 🇦🇪", "rating": 4}, ] TOOLS = [ @@ -94,6 +153,13 @@ BLOG_POSTS = [ {"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]}, {"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "nomadro", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]}, {"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]}, + {"id": "4", "slug": "medellin-spring-city", "title": "麦德林春城试住 30 天笔记", "excerpt": "时区、安全区、联合办公与西语社区初体验。", "emoji": "🌺", "author": "Sofia", "published_at": "2026-03-05", "read_time": 9, "tags": ["麦德林", "拉美", "试住"]}, + {"id": "5", "slug": "tbilisi-tax-base", "title": "第比利斯当税务基地靠谱吗?", "excerpt": "远程签证、小企业税与旧城生活成本实测。", "emoji": "🍷", "author": "Devon", "published_at": "2026-03-18", "read_time": 11, "tags": ["格鲁吉亚", "税务", "欧洲"]}, + {"id": "6", "slug": "dali-slow-create", "title": "大理慢创作:合租、网速与社群", "excerpt": "国内游民城市怎么住得久又不散漫。", "emoji": "🏔️", "author": "Lina", "published_at": "2026-04-02", "read_time": 7, "tags": ["大理", "内容", "国内"]}, + {"id": "7", "slug": "berlin-startup-nomad", "title": "柏林自由职业与开源社群指南", "excerpt": "签证路径、共享办公与每周活动清单。", "emoji": "🎨", "author": "Ken", "published_at": "2026-04-20", "read_time": 10, "tags": ["柏林", "创业", "开源"]}, + {"id": "8", "slug": "dubai-hub-month", "title": "迪拜一个月跳板旅居怎么排期", "excerpt": "签证、炎热季节、航班中转与办公点选择。", "emoji": "🏙️", "author": "Omar", "published_at": "2026-05-01", "read_time": 8, "tags": ["迪拜", "中东", "枢纽"]}, + {"id": "9", "slug": "seoul-workation", "title": "首尔 Workation:网速、便利与成本", "excerpt": "C-3-8 签证与江南区办公踩点。", "emoji": "🏙️", "author": "Yuki", "published_at": "2026-05-16", "read_time": 9, "tags": ["首尔", "韩国", "网速"]}, + {"id": "10", "slug": "first-month-checklist", "title": "旅居第一月清单:从落地到稳定输出", "excerpt": "SIM、短租、联合办公、社群与预算五步走。", "emoji": "✅", "author": "nomadro", "published_at": "2026-06-01", "read_time": 6, "tags": ["清单", "新手", "落地"]}, ] BLOG_CONTENT: dict[str, str] = { @@ -217,6 +283,102 @@ A: 满足居住要求后可以申请永居或入籍。""", 3. 🏦 使用 Wise 等工具便于跨境汇款记录 4. 👨‍💼 收入超过一定金额建议聘请税务顾问 5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""", + + "medellin-spring-city": """## 为什么麦德林? + +四季如春、生活成本可控,且贴近北美时区,适合接美加客户。Laureles / El Poblado 是游民常见落脚区。 + +## 30 天试住建议 + +1. 前 7 天住短租,实测网速与噪音 +2. 办本地 SIM,备份热点 +3. 参加 2 场线下 meetup,验证社区匹配度 +4. 确定联合办公月票再签长租 + +## 预算参考(月) + +- 住宿:¥2,000–3,500 +- 餐饮:¥1,200–1,800 +- 交通:¥300–500 +- 办公:¥400–800""", + + "tbilisi-tax-base": """## 远程签证 + 小企业税 + +格鲁吉亚对远程工作者友好,许多人会评估本地公司与 1% 税路径(需自行核实最新政策)。 + +## 生活感受 + +旧城与 Vake 区咖啡馆多,适合深度工作;冬季偏冷,夏天舒服。 + +## 注意 + +- 银行开户与本地手机号流程可能变动 +- 税务方案务必咨询持证顾问 +- 把「试住 30 天」和「税务落地」分开决策""", + + "dali-slow-create": """## 适合谁 + +内容创作者、独立开发者、想放慢节奏但仍保持产出的人。 + +## 实操建议 + +- 合租优先选稳定 WiFi 与独立工作桌 +- 用固定作息对抗「度假感」 +- 主动加入本地创作者局,避免社交孤岛 + +## 成本 + +月生活费常可控制在 ¥3,500–5,000,视合租与出行频率而定。""", + + "berlin-startup-nomad": """## 柏林为什么吸引游民 + +开源、设计、创业活动密度高,共享办公选择多。 + +## 落地要点 + +- 提前规划居留/自由职业路径 +- 冬天日照短,准备补光与室内运动 +- 德语不是必须,但能明显提升生活便利 + +## 每周节奏 + +2 天联合办公 + 1 场 meetup + 周末短途,比纯咖啡馆更稳。""", + + "dubai-hub-month": """## 适合当跳板 + +国际航班密集,签证路径相对清晰,适合中东/欧亚中转月。 + +## 炎热季节 + +夏季高温,深度工作尽量安排在空调联合办公;户外活动放早晚。 + +## 预算 + +整体偏高,建议按「枢纽月」而非「低成本旅居」来规划。""", + + "seoul-workation": """## 网速与便利 + +首尔网速与城市便利度极强,适合需要高频协作的产品/工程角色。 + +## 成本与节奏 + +住宿与餐饮成本接近一线城市;用便利店与共享办公控制变量。 + +## 签证 + +关注 Workation / 相关短期远程路径的最新材料要求。""", + + "first-month-checklist": """## 落地五步 + +1. **连接**:落地办 SIM,测公寓与办公点网速 +2. **住处**:先短租 7–14 天,再决定长租 +3. **办公**:固定 1 个主点 + 2 个备份咖啡馆 +4. **社群**:报名 1 场 meetup,加 1 个本地群 +5. **预算**:用第一周真实账单校准月预算 + +## 输出稳定 + +每天保护 4 小时深度工作,旅行探索放在下午后段。""", } TICKER_MESSAGES = [ @@ -226,4 +388,10 @@ TICKER_MESSAGES = [ "🏝️ 巴厘岛 Co-working 今日 89% 满座", "📶 清迈平均网速 95Mbps", "🌅 东京远程工作者满意度 9.1", + "🌺 Sofia 在麦德林办完本地 SIM", + "🍷 Devon 把下一站设为第比利斯", + "🎨 Ken 报名柏林开源 Meetup", + "🏙️ Omar 从迪拜中转飞往里斯本", + "📷 Lina 在大理发布本周创作日志", + "🇰🇷 Yuki 实测首尔联合办公 220Mbps", ] diff --git a/backend/app/data/platform_data.py b/backend/app/data/platform_data.py index fec8adf..320c6be 100644 --- a/backend/app/data/platform_data.py +++ b/backend/app/data/platform_data.py @@ -37,6 +37,60 @@ SERVICES = [ "category": "职业", "provider": "HR 志愿者", }, + { + "id": "coliving-match", + "title": "合租匹配咨询", + "description": "按城市、预算与作息匹配合租对象,附沟通话术模板。", + "emoji": "🏡", + "price": "¥149 起", + "category": "住宿", + "provider": "社区主理人", + }, + { + "id": "insurance-pick", + "title": "国际保险选型", + "description": "按年龄、目的地与既往史对比 SafetyWing / Genki / 本地险。", + "emoji": "🏥", + "price": "¥99 起", + "category": "安全", + "provider": "保险顾问", + }, + { + "id": "content-pack", + "title": "城市内容素材包", + "description": "可商用照片 + 短视频 B-roll,覆盖咖啡馆与联合办公场景。", + "emoji": "📷", + "price": "¥259 起", + "category": "内容", + "provider": "创作者网络", + }, + { + "id": "plan-coach", + "title": "90 天旅居计划辅导", + "description": "一起排城市顺序、预算与签证节点,输出可执行计划表。", + "emoji": "🗓️", + "price": "¥499 起", + "category": "规划", + "provider": "nomadro 教练", + }, + { + "id": "bank-setup", + "title": "跨境账户开户辅导", + "description": "Wise / Revolut / 本地银行材料清单与常见拒因规避。", + "emoji": "💳", + "price": "¥179 起", + "category": "财务", + "provider": "财务志愿者", + }, + { + "id": "meetup-host-coach", + "title": "线下活动主办陪跑", + "description": "从选题、场地到 RSVP 转化,帮你办成第一场同城局。", + "emoji": "🎉", + "price": "¥399 起", + "category": "社区", + "provider": "活动主理人", + }, ] VIDEOS = [ @@ -79,6 +133,123 @@ VIDEOS = [ "tags": ["印尼", "办公"], "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", }, + { + "id": "medellin-30d", + "slug": "medellin-30d", + "title": "麦德林 30 天试住 vlog", + "excerpt": "安全区、西语学习与北美时区接单节奏。", + "emoji": "🌺", + "duration": "16:20", + "guest": "Sofia", + "city": "麦德林", + "published_at": "2026-03-28", + "tags": ["哥伦比亚", "试住"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "tbilisi-tax", + "slug": "tbilisi-tax", + "title": "第比利斯税务基地访谈", + "excerpt": "远程签证、开户与旧城生活成本。", + "emoji": "🍷", + "duration": "21:05", + "guest": "Devon", + "city": "第比利斯", + "published_at": "2026-04-05", + "tags": ["格鲁吉亚", "税务"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "berlin-oss", + "slug": "berlin-oss", + "title": "柏林开源 Meetup 一日", + "excerpt": "创业办公空间与技术社交怎么玩。", + "emoji": "🎨", + "duration": "14:48", + "guest": "Ken", + "city": "柏林", + "published_at": "2026-04-22", + "tags": ["德国", "开源"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "dali-create", + "slug": "dali-create", + "title": "大理慢创作一周", + "excerpt": "合租、网速与内容产出作息。", + "emoji": "📷", + "duration": "12:55", + "guest": "Lina", + "city": "大理", + "published_at": "2026-05-03", + "tags": ["大理", "内容"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "dubai-hub", + "slug": "dubai-hub", + "title": "迪拜枢纽月怎么排", + "excerpt": "签证、炎热季节与航班中转策略。", + "emoji": "🏙️", + "duration": "13:40", + "guest": "Omar", + "city": "迪拜", + "published_at": "2026-05-18", + "tags": ["迪拜", "中东"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "seoul-net", + "slug": "seoul-net", + "title": "首尔网速与深夜办公", + "excerpt": "江南联合办公实测与 Workation 签证提示。", + "emoji": "🇰🇷", + "duration": "11:22", + "guest": "Yuki", + "city": "首尔", + "published_at": "2026-06-02", + "tags": ["韩国", "网速"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "mexico-food", + "slug": "mexico-food", + "title": "墨西哥城美食与办公平衡", + "excerpt": "Roma Norte 咖啡馆踩点与时区协作。", + "emoji": "🌮", + "duration": "15:10", + "guest": "Alex", + "city": "墨西哥城", + "published_at": "2026-06-15", + "tags": ["墨西哥", "美食"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "first-month", + "slug": "first-month", + "title": "旅居第一月:从慌乱到稳定", + "excerpt": "SIM、短租、社群与预算五步法。", + "emoji": "✅", + "duration": "10:05", + "guest": "nomadro", + "city": "线上", + "published_at": "2026-06-28", + "tags": ["新手", "清单"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, + { + "id": "barcelona-visa", + "slug": "barcelona-visa", + "title": "巴塞罗那 Nomad Visa 体验", + "excerpt": "材料、审批周期与地中海生活成本。", + "emoji": "🏖️", + "duration": "19:30", + "guest": "Sara", + "city": "巴塞罗那", + "published_at": "2026-07-08", + "tags": ["西班牙", "签证"], + "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + }, ] MEMBER_MAP = [ @@ -90,6 +261,14 @@ MEMBER_MAP = [ {"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": "🏙️"}, + {"name": "Devon", "city": "柏林", "lat": 52.52, "lng": 13.40, "emoji": "🖥️"}, + {"name": "Sara", "city": "麦德林", "lat": 6.25, "lng": -75.56, "emoji": "🌺"}, + {"name": "Nina", "city": "第比利斯", "lat": 41.72, "lng": 44.79, "emoji": "🍷"}, + {"name": "Jin", "city": "首尔", "lat": 37.57, "lng": 126.98, "emoji": "🇰🇷"}, + {"name": "Priya", "city": "里斯本", "lat": 38.74, "lng": -9.15, "emoji": "💻"}, + {"name": "Tom", "city": "清迈", "lat": 18.80, "lng": 98.97, "emoji": "☕"}, + {"name": "Mia", "city": "巴厘岛", "lat": -8.65, "lng": 115.14, "emoji": "🧘"}, + {"name": "Leo", "city": "墨西哥城", "lat": 19.42, "lng": -99.16, "emoji": "🚀"}, ] CITY_RANKING = [ @@ -98,12 +277,23 @@ CITY_RANKING = [ {"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"}, {"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"}, {"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"}, + {"slug": "medellin", "name": "麦德林", "score": 88, "nomads": "6k+"}, + {"slug": "tbilisi", "name": "第比利斯", "score": 86, "nomads": "5k+"}, + {"slug": "berlin", "name": "柏林", "score": 83, "nomads": "7k+"}, + {"slug": "dali", "name": "大理", "score": 82, "nomads": "4k+"}, + {"slug": "seoul", "name": "首尔", "score": 81, "nomads": "3k+"}, + {"slug": "tokyo", "name": "东京", "score": 80, "nomads": "4k+"}, + {"slug": "dubai", "name": "迪拜", "score": 79, "nomads": "5k+"}, ] DAILY_TIPS = { "1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"}, "2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"}, "3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"}, + "4": {"title": "第四天:加入同城社群", "body": "报名一场 meetup,比独自逛咖啡馆更快建立支持网络。"}, + "5": {"title": "第五天:备份通讯方案", "body": "本地 SIM + eSIM 热点双备份,开会前先压测上行带宽。"}, + "6": {"title": "第六天:校准月预算", "body": "用第一周真实账单更新计划表,别只信网上平均值。"}, + "7": {"title": "第七天:固定作息", "body": "保护每日 4 小时深度工作,探索放在下午后段。"}, } WEATHER_CITIES = { @@ -113,4 +303,10 @@ WEATHER_CITIES = { "mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"}, "barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"}, "tokyo": {"lat": 35.68, "lng": 139.69, "name": "东京"}, + "berlin": {"lat": 52.52, "lng": 13.40, "name": "柏林"}, + "dali": {"lat": 25.60, "lng": 100.27, "name": "大理"}, + "seoul": {"lat": 37.57, "lng": 126.98, "name": "首尔"}, + "medellin": {"lat": 6.25, "lng": -75.56, "name": "麦德林"}, + "dubai": {"lat": 25.20, "lng": 55.27, "name": "迪拜"}, + "tbilisi": {"lat": 41.72, "lng": 44.79, "name": "第比利斯"}, } diff --git a/backend/app/data/social_profiles.py b/backend/app/data/social_profiles.py index 73a5dcc..f20eef9 100644 --- a/backend/app/data/social_profiles.py +++ b/backend/app/data/social_profiles.py @@ -4,7 +4,7 @@ MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explo CANDIDATE_PROFILES = [ { - "id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiang-mai", + "id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiangmai", "gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。", "photo": "🧳", "tags": ["远程", "咖啡", "徒步"], "lookingFor": ["friends", "dating", "explore"], @@ -22,7 +22,7 @@ CANDIDATE_PROFILES = [ "lookingFor": ["roommate", "friends"], }, { - "id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico-city", + "id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico", "gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。", "photo": "🚀", "tags": ["独立开发", "拉美"], "lookingFor": ["cofounder", "friends", "dating"], @@ -51,4 +51,52 @@ CANDIDATE_PROFILES = [ "photo": "🖥️", "tags": ["DevOps", "开源"], "lookingFor": ["cofounder", "friends"], }, + { + "id": "p9", "userId": "u9", "name": "Sofia", "location": "麦德林", "citySlug": "medellin", + "gender": "女", "single": "是", "bio": "自由设计师,北美时区接单,爱西语咖啡局。", + "photo": "🌺", "tags": ["设计", "西语", "拉美"], + "lookingFor": ["friends", "dating", "explore"], + }, + { + "id": "p10", "userId": "u10", "name": "Omar", "location": "迪拜", "citySlug": "dubai", + "gender": "男", "single": "是", "bio": "增长顾问,常把迪拜当欧亚跳板。", + "photo": "🏙️", "tags": ["增长", "中东", "航班"], + "lookingFor": ["friends", "partner", "explore"], + }, + { + "id": "p11", "userId": "u11", "name": "Nina", "location": "第比利斯", "citySlug": "tbilisi", + "gender": "女", "single": "是", "bio": "独立顾问,研究税务友好基地与旧城生活。", + "photo": "🍷", "tags": ["税务", "顾问", "欧洲"], + "lookingFor": ["friends", "cofounder"], + }, + { + "id": "p12", "userId": "u12", "name": "Jin", "location": "首尔", "citySlug": "seoul", + "gender": "男", "single": "是", "bio": "移动端工程师,沉迷深夜联合办公。", + "photo": "🇰🇷", "tags": ["移动端", "效率", "网速"], + "lookingFor": ["friends", "roommate", "explore"], + }, + { + "id": "p13", "userId": "u13", "name": "Priya", "location": "里斯本", "citySlug": "lisbon", + "gender": "女", "single": "是", "bio": "产品设计师,喜欢海边徒步与博物馆。", + "photo": "🌊", "tags": ["产品设计", "徒步"], + "lookingFor": ["dating", "friends"], + }, + { + "id": "p14", "userId": "u14", "name": "Tom", "location": "清迈", "citySlug": "chiangmai", + "gender": "男", "single": "是", "bio": "后端工程师,咖啡店常客,周末骑摩托。", + "photo": "☕", "tags": ["后端", "咖啡", "摩托"], + "lookingFor": ["friends", "cofounder", "explore"], + }, + { + "id": "p15", "userId": "u15", "name": "Mia", "location": "巴厘岛", "citySlug": "bali", + "gender": "女", "single": "是", "bio": "瑜伽老师兼内容创作者,寻找同频合租。", + "photo": "🧘", "tags": ["瑜伽", "内容", "合租"], + "lookingFor": ["roommate", "friends", "partner"], + }, + { + "id": "p16", "userId": "u16", "name": "Leo", "location": "墨西哥城", "citySlug": "mexico", + "gender": "男", "single": "是", "bio": "创业者,做拉美远程协作工具。", + "photo": "🌮", "tags": ["创业", "拉美", "SaaS"], + "lookingFor": ["cofounder", "friends", "dating"], + }, ] diff --git a/backend/app/routers/social.py b/backend/app/routers/social.py index ea01048..f9b31c3 100644 --- a/backend/app/routers/social.py +++ b/backend/app/routers/social.py @@ -25,14 +25,36 @@ def _token_user(authorization: str | None) -> dict: async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)): user = _token_user(authorization) profile = social_store.join_member(user["id"], user["name"], body.model_dump()) - return {"success": True, "profile": profile} + return {"success": True, "profile": _profile_payload(profile, user["id"])} @router.get("/join/status") async def join_status(authorization: str | None = Header(None)): user = _token_user(authorization) - profile = social_store._profiles.get(user["id"]) - return {"complete": bool(profile), "profile": profile} + profile = social_store.get_public_profile(user["id"]) + return {"complete": bool(profile), "profile": _profile_payload(profile, user["id"]) if profile else None} + + +def _profile_payload(profile: dict | None, user_id: str) -> dict: + if not profile: + return {} + return { + "id": profile.get("id") or f"user-{user_id}", + "userId": profile.get("userId") or user_id, + "name": profile.get("name") or "游民", + "location": profile.get("location") or "", + "citySlug": profile.get("citySlug") or "", + "gender": profile.get("gender") or "", + "single": profile.get("single") or "", + "bio": profile.get("bio") or "", + "photo": profile.get("photo") or "🧑‍💻", + "tags": profile.get("tags") or [], + "lookingFor": profile.get("lookingFor") or profile.get("looking_for") or [], + } + + +def _has_match_profile(user_id: str) -> bool: + return social_store.get_public_profile(user_id) is not None @router.get("/matches/candidates", response_model=list[MatchProfile]) @@ -45,8 +67,13 @@ async def match_candidates( exclude_swiped: bool = Query(True), ): user = _token_user(authorization) - if user["id"] not in social_store._profiles: - raise HTTPException(400, "请先完成加入资料") + if not _has_match_profile(user["id"]): + # Soft-join so dating is usable after login without a stuck gate + social_store.join_member( + user["id"], + user.get("name") or "游民", + {"city": "全球", "lookingFor": [intent, "explore"], "bio": "刚加入 nomadro 匹配"}, + ) return [MatchProfile(**p) for p in social_store.list_candidates( user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped )] @@ -136,5 +163,5 @@ async def post_message(conv_id: str, body: SendMessageRequest, authorization: st async def vip_check(authorization: str | None = Header(None)): user = _token_user(authorization) vip = social_store.is_vip(user["id"]) - exp = (social_store._memberships.get(user["id"]) or {}).get("expires_at", 0) + exp = social_store.membership_expires_at(user["id"]) return VipStatus(vip=vip, expires_at=exp) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 8530249..4cc0ea6 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -400,13 +400,111 @@ def find_user_public_by_email(email: str) -> dict | None: return _user_profile(user) if user else None +# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase +_DEMO_SEED_USERS = [ + ("demo@nomadro.com", "demo123", "演示用户", "🧑‍💻"), + ("xiaolin@nomadro.com", "demo123", "小林", "🧳"), + ("marco@nomadro.com", "demo123", "Marco", "💻"), + ("yuki@nomadro.com", "demo123", "Yuki", "🎨"), + ("alex@nomadro.com", "demo123", "Alex", "🚀"), + ("sara@nomadro.com", "demo123", "Sara", "☕"), + ("ken@nomadro.com", "demo123", "Ken", "📊"), + ("lina@nomadro.com", "demo123", "Lina", "📷"), + ("devon@nomadro.com", "demo123", "Devon", "🖥️"), + ("sofia@nomadro.com", "demo123", "Sofia", "🌺"), + ("omar@nomadro.com", "demo123", "Omar", "🏙️"), + ("nina@nomadro.com", "demo123", "Nina", "🍷"), +] + +_DEMO_FAVORITES = { + "演示用户": ["chiangmai", "lisbon", "bali"], + "小林": ["chiangmai", "dali", "bali"], + "Marco": ["lisbon", "barcelona", "berlin"], + "Yuki": ["bali", "seoul", "tokyo"], + "Alex": ["mexico", "medellin", "lisbon"], + "Sara": ["barcelona", "lisbon", "medellin"], + "Ken": ["tokyo", "seoul", "berlin"], + "Lina": ["dali", "chiangmai", "bali"], + "Devon": ["berlin", "tbilisi", "lisbon"], + "Sofia": ["medellin", "mexico", "barcelona"], + "Omar": ["dubai", "lisbon", "tbilisi"], + "Nina": ["tbilisi", "berlin", "dubai"], +} + +_DEMO_PLANS = { + "演示用户": { + "items": [ + {"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 2, "note": "先稳住节奏"}, + {"slug": "bali", "name": "巴厘岛", "country": "印尼", "emoji": "🏝️", "cost": 4500, "months": 1, "note": ""}, + {"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 2, "note": "评估 D7"}, + ], + "meta": {"title": "演示旅居计划", "startMonth": "2026-10", "monthlyBudget": 8000, "checklist": {"visa": True, "sim": True}}, + "updated_at": 1, + }, + "小林": { + "items": [ + {"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 3, "note": ""}, + {"slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️", "cost": 4200, "months": 1, "note": "慢创作"}, + ], + "meta": {"title": "东南亚慢旅", "startMonth": "2026-09", "monthlyBudget": 4500, "checklist": {}}, + "updated_at": 1, + }, + "Marco": { + "items": [ + {"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 4, "note": ""}, + {"slug": "barcelona", "name": "巴塞罗那", "country": "西班牙", "emoji": "🏖️", "cost": 10500, "months": 1, "note": ""}, + ], + "meta": {"title": "欧洲基地", "startMonth": "2026-08", "monthlyBudget": 12000, "checklist": {"visa": True}}, + "updated_at": 1, + }, + "Sofia": { + "items": [ + {"slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺", "cost": 5200, "months": 2, "note": ""}, + {"slug": "mexico", "name": "墨西哥城", "country": "墨西哥", "emoji": "🌃", "cost": 6500, "months": 1, "note": ""}, + ], + "meta": {"title": "拉美春城", "startMonth": "2026-10", "monthlyBudget": 5500, "checklist": {}}, + "updated_at": 1, + }, + "Devon": { + "items": [ + {"slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷", "cost": 4800, "months": 3, "note": ""}, + {"slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨", "cost": 9800, "months": 1, "note": ""}, + ], + "meta": {"title": "税务友好试住", "startMonth": "2026-11", "monthlyBudget": 6000, "checklist": {}}, + "updated_at": 1, + }, +} + + +def _seed_demo_users() -> None: + for email, password, name, avatar in _DEMO_SEED_USERS: + if email not in _users: + created = register_user(email, password, name) + if not created: + continue + uid = _users[email]["id"] + _users[email]["avatar"] = avatar + favs = _DEMO_FAVORITES.get(name) + if favs and not (_favorites.get(uid) or []): + _favorites[uid] = list(favs) + plan = _DEMO_PLANS.get(name) + if plan and not ((_plans.get(uid) or {}).get("items") or []): + _plans[uid] = plan + _persist_json() + + # Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase if not use_pb(): if not _restore_json(): login_demo() elif "demo@nomadro.com" not in _users: login_demo() + _seed_demo_users() else: _migrate_json_to_pb() if not _get_account_by_email("demo@nomadro.com"): register_user("demo@nomadro.com", "demo123", "演示用户") + # Best-effort: ensure a few named demo accounts exist in PocketBase too + for email, password, name, _avatar in _DEMO_SEED_USERS[:6]: + if not _get_account_by_email(email): + register_user(email, password, name) diff --git a/backend/app/services/community_store.py b/backend/app/services/community_store.py index e18204f..755a4cc 100644 --- a/backend/app/services/community_store.py +++ b/backend/app/services/community_store.py @@ -577,18 +577,21 @@ def _jload() -> None: _j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8"))) except (OSError, json.JSONDecodeError): pass - if not _j.get("seeded"): + needs_seed = not _j.get("seeded") or not ( + (_j.get("meetups") or []) and (_j.get("discussions") or []) and (_j.get("gigs") or []) + ) + if needs_seed: _j.update({ "meetups": deepcopy(community_data.MEETUPS), "discussions": deepcopy(community_data.DISCUSSIONS), "replies": deepcopy(community_data.DISCUSSION_REPLIES), "gigs": deepcopy(getattr(community_data, "GIGS", [])), - "discussion_likes": {}, - "rsvps": {}, - "gig_apps": [], - "notifications": {}, - "feedback": [], - "views": {}, + "discussion_likes": _j.get("discussion_likes") or {}, + "rsvps": _j.get("rsvps") or {}, + "gig_apps": _j.get("gig_apps") or [], + "notifications": _j.get("notifications") or {}, + "feedback": _j.get("feedback") or [], + "views": _j.get("views") or {}, "seeded": True, }) _jsave() diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py index 8938aac..c7efc4b 100644 --- a/backend/app/services/social_store.py +++ b/backend/app/services/social_store.py @@ -73,6 +73,14 @@ def is_vip(user_id: str) -> bool: return bool(m and (m.get("expires_at") or 0) > int(time.time())) +def membership_expires_at(user_id: str) -> int: + if use_pb(): + row = safe_first("memberships", filter=f"userId={q(user_id)}") + return int((row or {}).get("expiresAt") or 0) + _jload() + return int(((_j.get("memberships") or {}).get(user_id) or {}).get("expires_at") or 0) + + def ensure_membership(user_id: str, days: int = 365) -> None: exp = int(time.time()) + days * 86400 if use_pb(): diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index cf9f6de..255636a 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,205 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-09-04", + tag: "匹配页修复", + items: [ + "修复 /dating 因后端错误检查不存在的 _profiles 而一直卡在「一键加入匹配」", + "登录后自动创建匹配资料并进入滑卡;VIP 状态读取同步修正", + ], + }, + { + date: "2026-09-04", + tag: "业务数据充实", + items: [ + "目的地增至 12 城,签证/博客/活动/讨论/零活/岗位/视频/服务/会员地图全面扩容(均 10+)", + "种子 12 个演示账号(密码 demo123)含收藏与旅居计划;修复社区空库不重灌的问题", + ], + }, + { + date: "2026-09-03", + tag: "城市工具决策出口", + items: [ + "命运转盘、网速、住宿、机票、周末灵感、落地清单、城市笔记结果区可写入计划并深链活动", + "落地清单 Meetup 任务直达同城活动;最近浏览补同城活动与计划入口;匹配器关闭清除 #matcher", + ], + }, + { + date: "2026-09-03", + tag: "工具转化 · 登录 CTA · 快捷键", + items: [ + "启动金目标 / 签证停留倒计时可写入计划并深链城市;下一站加入计划区分已在行程", + "登录成功 toast 可直达回流页;悬浮反馈支持 Esc;站内 M 键打开首页智能匹配(/#matcher)", + ], + }, + { + date: "2026-09-03", + tag: "登录回流 · 匹配器体验", + items: [ + "登录页识别数字游民/视频/岗位/AI/下一站/地图等回流目标,不再误标成个人中心", + "跑道计算器套用城市后可写入计划;首页智能匹配预加载 + 加载壳,支持 Esc 关闭", + ], + }, + { + date: "2026-09-03", + tag: "计算器 · 视频 · 分享回流", + items: [ + "费用/对比/首月成本工具结果可写入计划并深链城市详情与同城活动", + "视频详情按城市接活动环;岗位收藏 toast、分享复制 toast、私信空态补下一跳", + ], + }, + { + date: "2026-09-03", + tag: "报告 · 工具 · 发布回流", + items: [ + "数据报告:快照 toast 去下一站;排名/会员城市深链计划与同城活动", + "工具运行页结果旁增加计划/下一站/活动出口;afterTools 补活动环", + "发布赏金/发起活动/报名/加入匹配成功 toast 接回数字游民、社区或匹配;课程末课接赏金与活动", + ], + }, + { + date: "2026-09-03", + tag: "对比 · 收藏 · 反馈回流", + items: [ + "对比写入计划 / 收藏城市 / 个人中心收藏入计划:toast 可打开计划或我的收藏", + "反馈、投稿、悬浮反馈成功后可去社区;视频稍后再看空态与收藏 toast 回流", + "通知页登录门补活动与社区出口;旅居助手写入计划 toast 可打开计划中心", + ], + }, + { + date: "2026-09-03", + tag: "地图 · 匹配 · 会员资料", + items: [ + "智能匹配 / 下一站 / 世界地图写入计划后 toast 可打开计划中心;地图面板增加同城活动", + "会员资料与游民地图城市深链同城活动;加入资料 / 服务咨询成功 toast 接回匹配或社区", + ], + }, + { + date: "2026-09-03", + tag: "Toast CTA · Connect/Grow 出口", + items: [ + "Toast 支持行动链接(写入计划/报名/发帖后可直达下一站);城市页加入计划可打开计划中心", + "社区帖文识别城市名深链同城活动;匹配/私信空态与登录门增加活动与社区出口", + "Grow 环:赏金→计划/活动,数字游民学院用 afterDigital 接回探索", + ], + }, + { + date: "2026-09-03", + tag: "Explore ↔ Connect 漏斗", + items: [ + "城市页/对比/计划/下一站深链到同城活动;线上活动直达直播间", + "对比胜出城市一键活动 + 计划;NextStop / Live / 计划 RingNext 按情境分流", + "报名后展示同城社交建议;首页紧凑「接着探索」摘要(无足迹时隐藏)", + ], + }, + { + date: "2026-09-03", + tag: "工具箱 · AI 草稿", + items: [ + "工具箱目录与分类标签支持中英切换;全局搜索工具结果随语言匹配", + "页脚品牌标语与下一站预算单位接入 i18n;旅居助手提问草稿本机保存", + ], + }, + { + date: "2026-09-03", + tag: "智能匹配 · 首页 chrome", + items: [ + "首页智能匹配向导步骤/选项/结果与写入计划 toast 全面中英文化", + "快捷键帮助、游民小贴士、Cookie 条、新手引导、FAQ 空态接入 i18n", + ], + }, + { + date: "2026-09-03", + tag: "全局搜索 · 反馈 · 草稿", + items: [ + "全局搜索:触发器/占位/结果类型与路径快捷入口全面中英文化", + "悬浮反馈、分享/收藏按钮、地图加计划 toast、登录校验接入 i18n", + "私信与服务咨询支持本机草稿;反馈 FAB 与反馈页共用草稿键", + ], + }, + { + date: "2026-09-03", + tag: "个人中心 · 计划导出", + items: [ + "个人中心:成就徽章、统计、收藏/行程空态、快捷入口与头像提示全面中英文化", + "旅居计划:Markdown/纯文本导出与签证字段标签随语言切换", + "讨论回复支持本机草稿;匹配加入表单默认城市改为空白占位", + ], + }, + { + date: "2026-09-03", + tag: "城市深度 · 草稿 · 计划", + items: [ + "城市详情深度区块(指南/画像/成本/评价等)全面中英文化;快照模板随语言切换", + "发起活动与意见反馈表单支持本机草稿自动保存", + "下一站匹配理由、对比快照、计划空态/排序按钮、私信线程 RingNext 补齐 i18n", + ], + }, + { + date: "2026-09-03", + tag: "下一站 · 社区 · 城市", + items: [ + "下一站决策:预算/网速/气候/标签偏好本机记住;标签与气候中英展示", + "社区分类筛选与发帖分类中英标签;讨论回复提示与空态接入 i18n", + "城市详情:加入计划/分享/超预算提示与操作按钮全面中英文化", + ], + }, + { + date: "2026-09-03", + tag: "匹配 · 岗位 · 会员", + items: [ + "远程岗位支持搜索/标签筛选与本机收藏", + "匹配意图、配额、空态与私信列表全面中英文化", + "会员资料表单草稿自动保存", + ], + }, + { + date: "2026-09-03", + tag: "赏金 · 服务 · 报告", + items: [ + "赏金可收藏、申请说明本机草稿;发布赏金/内容投稿自动保存草稿", + "服务咨询与数据报告空态文案中英齐全;报告支持复制快照", + "赏金/投稿/服务校验提示全面接入 i18n", + ], + }, + { + date: "2026-09-03", + tag: "摘要 · 稍后再看 · 草稿", + items: [ + "视频「稍后再看」本机收藏;列表与详情可一键存取", + "个人中心「今日游民摘要」汇总计划就绪、报名、视频与最近 AI 提问", + "社区发帖草稿自动保存;通知/反馈/地图/视频空态文案中英齐全", + ], + }, + { + date: "2026-09-03", + tag: "日历 · 对比 · 搜索", + items: [ + "活动报名可导出 ICS(整表/单场);个人中心 RSVP 一键导入日历", + "城市对比:最近对比记录可恢复、复制对比快照", + "全局搜索空态展示最近浏览城市;通知设置补齐 RingNext 与文案", + ], + }, + { + date: "2026-09-03", + tag: "继续探索与个人中心", + items: [ + "最近浏览城市:详情页自动记录,首页与个人中心可续看、一键对比", + "个人中心展示已报名活动(含直播入口)与环导航", + "旅居助手:本地提问历史、中英文案与对比/写入计划闭环", + "城市详情「复制快照」;VIP 支付成功页补齐下一步", + ], + }, + { + date: "2026-09-01", + tag: "环导航与文案", + items: [ + "工具详情、发起活动、会员定价、学院课时、喜欢列表、成员主页补齐 RingNext", + "直播间门禁/布局、工具箱空态与搜索、活动登录提示接入中英文案", + "私信空态与发送失败提示、学院 VIP 提示与每日指南 i18n", + ], + }, { date: "2026-08-30", tag: "决策与发现闭环", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e9f851e..44b495e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3290,10 +3290,26 @@ img { max-width: 100%; display: block; } transform: translateY(20px); opacity: 0; animation: toastIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + pointer-events: auto; } .toast-stack .toast.show { opacity: 1; transform: translateY(0); } +.toast-action { + margin-left: 0.35rem; + padding: 0.25rem 0.55rem; + border-radius: var(--radius-sm, 6px); + background: rgba(255, 255, 255, 0.08); + color: var(--accent, #4ecdc4); + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} +.toast-action:hover { + background: rgba(255, 255, 255, 0.14); +} + @keyframes toastIn { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } @@ -5886,7 +5902,15 @@ img { max-width: 100%; display: block; } } .packing-emoji { font-size: 1.2rem; } -.packing-label { font-size: 0.9rem; } +.packing-label { font-size: 0.9rem; flex: 1; } +.arrival-meetup-link { + margin-left: auto; + color: var(--accent, #4ecdc4); + text-decoration: none; + font-weight: 600; + padding: 0 0.25rem; +} +.arrival-meetup-link:hover { text-decoration: underline; } /* ===== Season Guide ===== */ .season-card { @@ -10325,6 +10349,10 @@ img { max-width: 100%; display: block; } font-size: 2rem; } +.tool-runner-exits { + margin: 1.25rem 0 0.5rem; +} + .tool-loading { padding: 48px; text-align: center; @@ -10605,6 +10633,15 @@ img { max-width: 100%; display: block; } border-radius: var(--radius-md); } +.member-loc-link { + color: inherit; + text-decoration: none; +} +.member-loc-link:hover { + color: var(--accent, #4ecdc4); + text-decoration: underline; +} + .weather-strip { margin-top: 32px; } .weather-cards { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; } .weather-card { @@ -10613,6 +10650,16 @@ img { max-width: 100%; display: block; } border: var(--border-glass); border-radius: var(--radius-md); min-width: 100px; + display: flex; + flex-direction: column; + gap: 0.25rem; + text-decoration: none; + color: inherit; + transition: border-color 0.15s ease, transform 0.15s ease; +} +a.weather-card:hover { + border-color: rgba(78, 205, 196, 0.35); + transform: translateY(-1px); } .ai-reply { @@ -11225,3 +11272,506 @@ img { max-width: 100%; display: block; } color: var(--accent-3, #4ecdc4); text-decoration: underline; } + +/* ===== Recently viewed + AI history + profile RSVPs ===== */ +.recently-viewed { + padding: 2.5rem 0 1rem; +} +.recently-viewed.compact { + padding: 1.25rem 0; + margin-top: 1.5rem; +} +.recently-viewed-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} +.recently-viewed-head h2 { + margin: 0.25rem 0 0; + font-size: 1.35rem; +} +.recently-viewed.compact .recently-viewed-head h2 { + font-size: 1.15rem; +} +.recently-viewed-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} +.recently-viewed-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem; +} +.recently-viewed-card { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.85rem 1rem; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-md); + text-decoration: none; + color: inherit; + transition: transform 0.2s ease, border-color 0.2s ease; +} +.recently-viewed-card:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} +.recently-viewed-emoji { + font-size: 1.6rem; + line-height: 1; +} +.recently-viewed-card strong { + display: block; + font-size: 0.95rem; +} +.recently-viewed-country { + color: var(--text-muted); + font-weight: 500; +} +.recently-viewed-card p { + margin: 0.15rem 0 0; + font-size: 0.78rem; + color: var(--text-muted); +} +.recently-viewed-compare { + margin-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.ai-history { + margin-top: 2rem; +} +.ai-history-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} +.ai-history-head h2 { + margin: 0; + font-size: 1.1rem; +} +.ai-history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.ai-history-item { + width: 100%; + text-align: left; + padding: 0.85rem 1rem; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-md); + color: inherit; + cursor: pointer; + transition: border-color 0.2s ease; +} +.ai-history-item:hover { + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.ai-history-item strong { + display: block; + margin-bottom: 0.25rem; +} +.ai-history-item span { + font-size: 0.82rem; + color: var(--text-muted); +} + +.profile-rsvps { + margin-top: 2rem; +} +.profile-rsvp-list { + display: flex; + flex-direction: column; + gap: 0.65rem; +} +.profile-rsvp-card { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.85rem 1rem; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-md); +} +.profile-rsvp-card span { + font-size: 1.5rem; +} +.profile-rsvp-card strong { + display: block; +} +.profile-rsvp-card p { + margin: 0.15rem 0 0; + font-size: 0.8rem; + color: var(--text-muted); +} +.profile-rsvp-card .btn { + margin-left: auto; +} + +.compare-history { + margin: 0 0 1.5rem; + padding: 1rem 1.15rem; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); +} +.compare-history-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} +.compare-history-head h2 { + margin: 0; + font-size: 1rem; +} +.compare-history-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.compare-history-chip { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + width: 100%; + text-align: left; + padding: 0.7rem 0.9rem; + background: transparent; + border: var(--border-glass); + border-radius: var(--radius-md); + color: inherit; + cursor: pointer; +} +.compare-history-chip:hover { + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} +.compare-history-chip span { + font-size: 0.88rem; +} +.compare-history-chip strong { + font-size: 0.78rem; + color: var(--accent-3, #4ecdc4); + white-space: nowrap; +} +.search-recent-inline { + margin-bottom: 1rem; +} +.profile-section-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +/* ===== Digest + watch later ===== */ +.nomad-digest { + margin: 1.75rem 0; + padding: 1.25rem 1.35rem; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); +} +.nomad-digest-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} +.nomad-digest-head h2 { + margin: 0.2rem 0 0; + font-size: 1.2rem; +} +.nomad-digest-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; +} +.nomad-digest-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.9rem 1rem; + background: color-mix(in srgb, var(--bg-body) 55%, transparent); + border: var(--border-glass); + border-radius: var(--radius-md); + text-decoration: none; + color: inherit; + transition: transform 0.2s ease, border-color 0.2s ease; +} +.nomad-digest-card:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} +.nomad-digest-card strong { + font-size: 1.25rem; +} +.nomad-digest-card span { + font-size: 0.85rem; +} +.nomad-digest-card small { + font-size: 0.75rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.nomad-digest-recent { + margin-top: 1rem; +} +.nomad-digest-recent > span { + display: block; + margin-bottom: 0.5rem; + font-size: 0.85rem; + color: var(--text-muted); +} +.nomad-digest-chips { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} +.nomad-digest.compact { + margin: 1rem 0 0.25rem; + padding: 1rem 1.15rem; +} +.nomad-digest.compact h2 { + font-size: 1.05rem; +} +.nomad-digest.compact .section-desc { + margin-bottom: 0; + font-size: 0.9rem; +} + +.plan-stop-links { + margin: 0.35rem 0 0.55rem; +} +.plan-city-meetup { + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; +} +.plan-city-meetup:hover { + text-decoration: underline; +} + +.meetup-peers { + margin-top: 0.85rem; + padding-top: 0.75rem; + border-top: var(--border-glass); +} +.meetup-peers-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + font-size: 0.9rem; + color: var(--text-muted); +} +.meetup-peers-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.meetup-peers-list li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} +.meetup-peers-list a { + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} +.meetup-peers-list strong { + font-size: 0.92rem; +} +.meetup-peers-list span { + font-size: 0.78rem; + color: var(--text-muted); +} + +.live-exit { + padding: 1.25rem 0 2rem; +} + +.watch-later-panel { + margin-bottom: 1.5rem; + padding: 1rem 1.15rem; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); +} +.watch-later-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} +.watch-later-head h2 { + margin: 0; + font-size: 1.05rem; +} +.watch-later-list { + display: flex; + flex-direction: column; + gap: 0.45rem; +} +.watch-later-chip { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.65rem; + align-items: center; + padding: 0.65rem 0.85rem; + border: var(--border-glass); + border-radius: var(--radius-md); + text-decoration: none; + color: inherit; +} +.watch-later-chip:hover { + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.watch-later-chip small { + color: var(--text-muted); + font-size: 0.75rem; +} +.video-card-wrap { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.video-card-wrap > a { + text-decoration: none; + color: inherit; +} +.video-card-wrap > .btn { + align-self: flex-start; +} + +.saved-gigs-panel { + margin-bottom: 1.5rem; + padding: 1rem 1.15rem; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); +} +.saved-gigs-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} +.saved-gigs-head h2 { + margin: 0; + font-size: 1.05rem; +} +.saved-gigs-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.saved-gig-chip { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.55rem 0.8rem; + border: var(--border-glass); + border-radius: var(--radius-md); + text-decoration: none; + color: inherit; + max-width: 220px; +} +.saved-gig-chip:hover { + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.saved-gig-chip small { + color: var(--text-muted); + font-size: 0.75rem; +} +.gig-card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 0.5rem; +} +.gig-card-top h3 { + margin: 0; +} + +.saved-jobs-panel { + margin-bottom: 1.5rem; + padding: 1rem 1.15rem; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-lg); +} +.saved-jobs-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} +.saved-jobs-head h2 { + margin: 0; + font-size: 1.05rem; +} +.saved-jobs-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.saved-job-chip { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.55rem 0.8rem; + border: var(--border-glass); + border-radius: var(--radius-md); + text-decoration: none; + color: inherit; + max-width: 240px; +} +.saved-job-chip:hover { + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.saved-job-chip small { + color: var(--text-muted); + font-size: 0.75rem; +} +.jobs-toolbar { + margin-bottom: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index a2bc974..c3ec760 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -27,7 +27,7 @@ function LoginForm() { const [loading, setLoading] = useState(false); const goAfterAuth = (msg: string) => { - toast(msg); + toast(msg, "success", { href: meta.path, label: meta.label }); setTimeout(() => { router.replace(meta.path); }, 400); }; @@ -36,11 +36,11 @@ function LoginForm() { setError(""); if (mode === "register") { if (!name.trim() || name.trim().length < 2) { - setError("昵称至少 2 个字符"); + setError(t.login.nameMin); return; } if (password.length < 6) { - setError("密码至少 6 位"); + setError(t.login.pwdMin); return; } } @@ -122,19 +122,19 @@ function LoginForm() { minLength={6} autoComplete={mode === "login" ? "current-password" : "new-password"} /> - {mode === "register" && (

{password.length === 0 - ? "至少 6 位,建议包含字母与数字" + ? t.login.pwdHint : password.length < 6 - ? `还差 ${6 - password.length} 位` + ? t.login.pwdNeed.replace("{n}", String(6 - password.length)) : password.length < 10 - ? "可用 · 再长一点更安全" - : "强度不错"} + ? t.login.pwdOk + : t.login.pwdStrong}

)} diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 59af3ba..ff03657 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -5,14 +5,21 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; import { api } from "@/lib/api"; -import type { Destination, ProfileStats, TripItem } from "@/lib/types"; +import type { Destination, Meetup, ProfileStats, TripItem } from "@/lib/types"; import SiteShell from "@/components/SiteShell"; import FavoriteButton from "@/components/FavoriteButton"; +import RecentlyViewed from "@/components/RecentlyViewed"; +import NomadDigest from "@/components/NomadDigest"; +import RingNext from "@/components/RingNext"; +import { useRingSteps } from "@/lib/rings"; +import { useI18n } from "@/lib/i18n"; +import type { Dict } from "@/lib/i18n"; import { loadTrip } from "@/lib/tripStorage"; import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta"; import { PLAN_SYNC_EVENT } from "@/lib/planSync"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { useToast } from "@/lib/toast"; +import { buildMeetupIcs, downloadMeetupIcs } from "@/lib/meetupIcs"; interface Badge { emoji: string; @@ -21,15 +28,26 @@ interface Badge { earned: boolean; } -function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number, readiness: number): Badge[] { - const tripMonths = trip.reduce((s, t) => s + t.months, 0); +function getBadges( + p: Dict["profile"], + stats: ProfileStats | null, + trip: TripItem[], + favCount: number, + readiness: number +): Badge[] { + const tripMonths = trip.reduce((s, item) => s + item.months, 0); return [ - { emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true }, - { emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 }, - { emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 }, - { emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 }, - { emoji: "✅", label: "出发就绪", desc: "就绪清单 ≥ 50%", earned: readiness >= 50 }, - { emoji: "🌍", label: "环球游民", desc: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 }, + { emoji: "🌱", label: p.badgeNewbie, desc: p.badgeNewbieDesc, earned: true }, + { emoji: "❤️", label: p.badgeCollector, desc: p.badgeCollectorDesc, earned: favCount >= 3 }, + { emoji: "🗺️", label: p.badgePlanner, desc: p.badgePlannerDesc, earned: trip.length >= 3 }, + { emoji: "📅", label: p.badgeLongStay, desc: p.badgeLongStayDesc, earned: tripMonths >= 6 }, + { emoji: "✅", label: p.badgeReady, desc: p.badgeReadyDesc, earned: readiness >= 50 }, + { + emoji: "🌍", + label: p.badgeGlobal, + desc: p.badgeGlobalDesc, + earned: (stats?.destinations_explored ?? 0) >= 5, + }, ]; } @@ -37,8 +55,11 @@ export default function ProfilePage() { const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth(); const router = useRouter(); const { toast } = useToast(); + const { t } = useI18n(); + const rings = useRingSteps(); const [stats, setStats] = useState(null); const [favoriteDests, setFavoriteDests] = useState([]); + const [rsvps, setRsvps] = useState([]); const [trip, setTrip] = useState([]); const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(true); @@ -50,7 +71,7 @@ export default function ProfilePage() { e.target.value = ""; if (!file || !token) return; if (file.size > 5 * 1024 * 1024) { - toast("图片请小于 5MB", "error"); + toast(t.profile.avatarTooBig, "error"); return; } setUploadingAvatar(true); @@ -58,9 +79,9 @@ export default function ProfilePage() { const uploaded = await api.uploadMedia(token, file, "avatar"); await api.updateProfile(token, { avatar: uploaded.url }); await refreshUser?.(); - toast("头像已更新", "success"); + toast(t.profile.avatarOk, "success"); } catch (err) { - toast(err instanceof Error ? err.message : "上传失败", "error"); + toast(err instanceof Error ? err.message : t.profile.avatarFail, "error"); } finally { setUploadingAvatar(false); } @@ -81,10 +102,18 @@ export default function ProfilePage() { api.getProfileStats(token), favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]), api.getVipStatus(token).catch(() => ({ vip: false, expires_at: 0 })), - ]).then(([s, f, v]) => { + Promise.all([ + api.getMyRsvps(token).catch(() => ({ ids: [] as string[] })), + api.getMeetups().catch(() => [] as Meetup[]), + ]).then(([mine, all]) => { + const idSet = new Set(mine.ids || []); + return all.filter((m) => idSet.has(m.id)).slice(0, 6); + }), + ]).then(([s, f, v, myMeetups]) => { setStats(s); setFavoriteDests(f); setVip(Boolean(v.vip)); + setRsvps(myMeetups); }).catch(() => {}).finally(() => setLoading(false)); refreshPlan(); @@ -92,31 +121,37 @@ export default function ProfilePage() { return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan); }, [token, favorites, router]); - const totalMonths = trip.reduce((s, t) => s + t.months, 0); - const totalCost = trip.reduce((s, t) => s + t.cost * t.months, 0); + const totalMonths = trip.reduce((s, item) => s + item.months, 0); + const totalCost = trip.reduce((s, item) => s + item.cost * item.months, 0); const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0; const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length; const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100); const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget; const badges = useMemo( - () => getBadges(stats, trip, favoriteDests.length, readiness), - [stats, trip, favoriteDests.length, readiness] + () => getBadges(t.profile, stats, trip, favoriteDests.length, readiness), + [t.profile, stats, trip, favoriteDests.length, readiness] ); const earnedCount = badges.filter((b) => b.earned).length; - const favsNotInTrip = favoriteDests.filter((d) => !trip.some((t) => t.slug === d.slug)); + const favsNotInTrip = favoriteDests.filter((d) => !trip.some((item) => item.slug === d.slug)); const addFavsToPlan = () => { if (favsNotInTrip.length === 0) { - toast("收藏城市都已在计划中", "info"); + toast(t.profile.favsAlready, "info", { href: "/plan", label: t.profile.openPlan }); return; } const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1); refreshPlan(); - toast(`已将 ${added} 座收藏城市写入计划`); + toast(t.profile.favsAdded.replace("{n}", String(added)), "success", { + href: "/plan", + label: t.profile.openPlan, + }); }; + const planTitle = + meta?.title && meta.title !== "我的旅居计划" ? meta.title : t.plan.defaultTitle; + if (!user) return null; return ( @@ -131,7 +166,7 @@ export default function ProfilePage() {
{user.avatar || "🧑‍💻"}
)}