Initial commit: NomadFlow full-stack digital nomad platform with Next.js, FastAPI, and PocketBase.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
9df4444cc5
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
frontend/node_modules/
|
||||||
|
backend/__pycache__/
|
||||||
|
backend/**/__pycache__/
|
||||||
|
backend/.env
|
||||||
|
frontend/.env.local
|
||||||
|
|
||||||
|
# PocketBase
|
||||||
|
pocketbase/pb_data/*
|
||||||
|
!pocketbase/pb_data/.gitkeep
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
frontend/.next/
|
||||||
|
frontend/out/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
205
README.md
Normal file
205
README.md
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# NomadFlow · 数字游民旅居平台
|
||||||
|
|
||||||
|
全栈数字游民旅居指南,采用 **Next.js + FastAPI + PocketBase** 架构。
|
||||||
|
品牌:**NomadFlow** — 用一行代码环游世界 🌏
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 前端 | Next.js 16 + React 19 | SSR/ISR、组件化 UI、Turbopack |
|
||||||
|
| 后端 | FastAPI | REST API、业务逻辑 |
|
||||||
|
| 数据库 | PocketBase | 数据持久化、管理后台 |
|
||||||
|
| 图表 | Chart.js (CDN) | 数据可视化 |
|
||||||
|
| 部署 | Vercel + Railway + Docker | 前后端分离部署 |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
nomadweb/
|
||||||
|
├── frontend/ # Next.js 前端应用
|
||||||
|
│ ├── src/app/ # 页面路由 (App Router)
|
||||||
|
│ ├── src/components/ # UI 组件 (30+)
|
||||||
|
│ └── src/lib/ # API 客户端、Auth、Toast
|
||||||
|
├── backend/ # FastAPI 后端
|
||||||
|
│ ├── app/routers/ # API 路由
|
||||||
|
│ ├── app/services/ # PocketBase、Auth 服务
|
||||||
|
│ └── app/data/ # Mock 降级数据
|
||||||
|
├── pocketbase/ # PocketBase 数据目录
|
||||||
|
├── scripts/ # 开发启动脚本 (dev.ps1 / dev.sh)
|
||||||
|
├── docker-compose.yml # 一键启动全部服务
|
||||||
|
├── index.html # 原始静态版(保留)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 方式一:本地开发(推荐)
|
||||||
|
|
||||||
|
**1. 启动 PocketBase(可选)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 下载: https://pocketbase.io/docs/
|
||||||
|
# 或使用 Docker:
|
||||||
|
docker run -d -p 8090:8090 -v ./pocketbase/pb_data:/pb_data ghcr.io/muchobien/pocketbase:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://localhost:8090/_/ 创建管理员(可选,不启动则自动使用 Mock 数据)。
|
||||||
|
|
||||||
|
**2. 启动 FastAPI 后端**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # 按需修改
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
API 文档: http://localhost:8000/docs
|
||||||
|
|
||||||
|
**3. 启动 Next.js 前端**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://localhost:3000
|
||||||
|
|
||||||
|
### 方式二:一键脚本(Windows)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\dev.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式三:Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
| 服务 | 地址 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | http://localhost:3000 |
|
||||||
|
| API | http://localhost:8000/docs |
|
||||||
|
| PocketBase | http://localhost:8090/_/ |
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 首页核心板块
|
||||||
|
|
||||||
|
| 板块 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 🗺️ 世界地图 | 交互式 SVG 热力图,点击查看城市详情 |
|
||||||
|
| 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 |
|
||||||
|
| 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 |
|
||||||
|
| 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 |
|
||||||
|
| 🗓️ 行程规划 | 多城市规划,预算汇总,分享/导入链接 |
|
||||||
|
| 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 |
|
||||||
|
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
|
||||||
|
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
|
||||||
|
| 💻 生活方式 | 交互式「游民一天」时间轴 |
|
||||||
|
| 📋 签证指南 | 难度筛选 + 进度条可视化 |
|
||||||
|
| 📝 博客 | 文章列表 + 阅读进度条详情页 |
|
||||||
|
| ❓ FAQ | 手风琴问答 |
|
||||||
|
|
||||||
|
### 交互与体验
|
||||||
|
|
||||||
|
- ✨ Canvas 粒子背景 + 品牌加载动画
|
||||||
|
- 🌓 深色/浅色主题切换(localStorage 持久化)
|
||||||
|
- 🔍 全局搜索 `Ctrl+K`(目的地/博客/签证/FAQ)
|
||||||
|
- 🧭 右侧浮动章节导航
|
||||||
|
- 📊 顶部滚动进度条
|
||||||
|
- ⚖️ 可视化城市对比(评分圆环 + 条形图)
|
||||||
|
- 🔔 全局 Toast 通知
|
||||||
|
- ⌨️ 快捷键:`M` 智能匹配、`?` 帮助面板
|
||||||
|
- 📱 响应式布局,移动端适配
|
||||||
|
|
||||||
|
### 用户系统
|
||||||
|
|
||||||
|
- 🔐 登录 / 注册 / 一键演示账号
|
||||||
|
- ❤️ 收藏目的地(同步 API)
|
||||||
|
- 👤 用户中心:统计、收藏、行程展示
|
||||||
|
- 演示账号:`demo@nomadflow.io` / `demo123`
|
||||||
|
|
||||||
|
### 后端 API
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/v1/destinations` | GET | 目的地列表(region/search/sort) |
|
||||||
|
| `/api/v1/destinations/{slug}` | GET | 目的地详情 |
|
||||||
|
| `/api/v1/destinations/compare` | POST | 城市对比(2–4 城) |
|
||||||
|
| `/api/v1/calculator` | POST | 费用计算 |
|
||||||
|
| `/api/v1/search` | GET | 全局搜索 |
|
||||||
|
| `/api/v1/blog` | GET | 博客列表 |
|
||||||
|
| `/api/v1/blog/{slug}` | GET | 博客详情 |
|
||||||
|
| `/api/v1/auth/register` | POST | 注册 |
|
||||||
|
| `/api/v1/auth/login` | POST | 登录 |
|
||||||
|
| `/api/v1/auth/demo` | GET | 演示登录 |
|
||||||
|
| `/api/v1/auth/favorites` | GET/POST | 收藏管理 |
|
||||||
|
| `/api/v1/charts/*` | GET | 图表数据 |
|
||||||
|
| `/api/v1/subscribe` | POST | 邮件订阅 |
|
||||||
|
|
||||||
|
> PocketBase 不可用时,API 自动降级到 `backend/app/data/mock_data.py`。
|
||||||
|
|
||||||
|
## 页面路由
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `/` | 首页(全部板块) |
|
||||||
|
| `/login` | 登录 / 注册 |
|
||||||
|
| `/profile` | 用户中心 |
|
||||||
|
| `/destinations/[slug]` | 目的地详情(城市画像、加入行程) |
|
||||||
|
| `/blog/[slug]` | 博客详情(阅读进度) |
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
### Backend (`backend/.env`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
POCKETBASE_URL=http://127.0.0.1:8090
|
||||||
|
POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
|
||||||
|
POCKETBASE_ADMIN_PASSWORD=admin123456
|
||||||
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (`frontend/.env.local`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
### 前端 → Vercel
|
||||||
|
|
||||||
|
1. 导入 `frontend/` 目录
|
||||||
|
2. 环境变量:`NEXT_PUBLIC_API_URL=https://your-api.example.com/api/v1`
|
||||||
|
3. 自动构建部署
|
||||||
|
|
||||||
|
### 后端 → Railway
|
||||||
|
|
||||||
|
1. 连接 `backend/` 目录
|
||||||
|
2. 配置 `POCKETBASE_URL`、`CORS_ORIGINS`
|
||||||
|
3. 读取 `railway.toml` 自动部署
|
||||||
|
|
||||||
|
### 全栈 → Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快捷键
|
||||||
|
|
||||||
|
| 按键 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `Ctrl+K` | 全局搜索 |
|
||||||
|
| `M` | 智能目的地匹配 |
|
||||||
|
| `?` | 快捷键帮助 |
|
||||||
|
| `Esc` | 关闭弹窗 |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
6
backend/.env.example
Normal file
6
backend/.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
POCKETBASE_URL=http://127.0.0.1:8090
|
||||||
|
POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
|
||||||
|
POCKETBASE_ADMIN_PASSWORD=admin123456
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8000
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
20
backend/app/config.py
Normal file
20
backend/app/config.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
pocketbase_url: str = "http://127.0.0.1:8090"
|
||||||
|
pocketbase_admin_email: str = "admin@nomadflow.io"
|
||||||
|
pocketbase_admin_password: str = "admin123456"
|
||||||
|
api_host: str = "0.0.0.0"
|
||||||
|
api_port: int = 8000
|
||||||
|
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
0
backend/app/data/__init__.py
Normal file
0
backend/app/data/__init__.py
Normal file
229
backend/app/data/mock_data.py
Normal file
229
backend/app/data/mock_data.py
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
"""Fallback data when PocketBase is unavailable."""
|
||||||
|
|
||||||
|
DESTINATIONS = [
|
||||||
|
{
|
||||||
|
"id": "1", "slug": "bali", "name": "巴厘岛", "country": "印尼", "emoji": "🏝️",
|
||||||
|
"tag": "东南亚 · 热带天堂", "description": "乌布的数字游民社区闻名全球,稻田间的 Co-working Space 和瑜伽文化让这里成为游民圣地。",
|
||||||
|
"region": "sea", "cost": 4500, "speed": 85, "temperature": 28, "rating": 9.2, "hue": 170,
|
||||||
|
"nomads_count": "12,000+", "highlights": ["🏄 冲浪与海滩生活", "🧘 瑜伽冥想文化", "💰 东南亚性价比之王", "🌴 热带气候全年温暖"],
|
||||||
|
"map_x": 720, "map_y": 310,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2", "slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊",
|
||||||
|
"tag": "欧洲 · 海滨明珠", "description": "D7 签证友好,阳光海岸与悠久历史的完美融合,欧洲数字游民的首选基地。",
|
||||||
|
"region": "europe", "cost": 9000, "speed": 120, "temperature": 22, "rating": 9.5, "hue": 220,
|
||||||
|
"nomads_count": "8,500+", "highlights": ["📋 D7 签证门槛低", "☀️ 300天阳光", "🎵 Fado 音乐文化", "🚋 复古有轨电车"],
|
||||||
|
"map_x": 430, "map_y": 195,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3", "slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️",
|
||||||
|
"tag": "东南亚 · 文化古城", "description": "数字游民大本营,咖啡文化与夜市生活的天堂,全球性价比最高的游民城市。",
|
||||||
|
"region": "sea", "cost": 3800, "speed": 95, "temperature": 30, "rating": 9.4, "hue": 45,
|
||||||
|
"nomads_count": "15,000+", "highlights": ["☕ 咖啡馆文化浓厚", "🏮 夜市与寺庙", "💰 月生活费最低", "🤝 游民社区最活跃"],
|
||||||
|
"map_x": 700, "map_y": 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4", "slug": "mexico", "name": "墨西哥城", "country": "墨西哥", "emoji": "🌃",
|
||||||
|
"tag": "拉美 · 活力之都", "description": "艺术、美食与科技交织,时区便利对接北美市场,拉美最具活力的游民城市。",
|
||||||
|
"region": "latam", "cost": 6500, "speed": 75, "temperature": 18, "rating": 8.8, "hue": 300,
|
||||||
|
"nomads_count": "5,200+", "highlights": ["🎨 街头艺术天堂", "🌮 世界美食之都", "🕐 北美时区友好", "💃 丰富夜生活"],
|
||||||
|
"map_x": 220, "map_y": 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5", "slug": "barcelona", "name": "巴塞罗那", "country": "西班牙", "emoji": "🏖️",
|
||||||
|
"tag": "欧洲 · 地中海", "description": "高迪建筑与创业生态并存,Nomad Visa 政策领先,地中海生活的理想之选。",
|
||||||
|
"region": "europe", "cost": 10500, "speed": 150, "temperature": 20, "rating": 9.1, "hue": 130,
|
||||||
|
"nomads_count": "6,800+", "highlights": ["🏛️ 高迪建筑奇迹", "🏖️ 地中海海滩", "📋 Nomad Visa 便利", "🍷 美食与夜生活"],
|
||||||
|
"map_x": 460, "map_y": 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6", "slug": "tokyo", "name": "东京", "country": "日本", "emoji": "🗼",
|
||||||
|
"tag": "亚洲 · 现代都市", "description": "极致效率与安全,适合追求高品质生活的远程工作者,亚洲科技之都。",
|
||||||
|
"region": "asia", "cost": 12000, "speed": 200, "temperature": 15, "rating": 8.6, "hue": 10,
|
||||||
|
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
|
||||||
|
"map_x": 820, "map_y": 210,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
VISAS = [
|
||||||
|
{"id": "1", "country": "葡萄牙", "flag": "🇵🇹", "name": "葡萄牙 D7 签证", "badge": "⭐ 推荐", "badge_type": "easy",
|
||||||
|
"duration": "2年,可续签", "income_req": "€760/月", "approval_time": "3-6 个月", "extra": "🏥 可享欧盟医疗", "difficulty": 35, "difficulty_label": "简单"},
|
||||||
|
{"id": "2", "country": "西班牙", "flag": "🇪🇸", "name": "西班牙 Nomad Visa", "badge": "🔥 热门", "badge_type": "hot",
|
||||||
|
"duration": "1年,可续3年", "income_req": "€2,160/月", "approval_time": "1-3 个月", "extra": "🌍 可申根区旅行", "difficulty": 45, "difficulty_label": "中等"},
|
||||||
|
{"id": "3", "country": "印尼", "flag": "🇮🇩", "name": "印尼 B211A 签证", "badge": "💰 低成本", "badge_type": "budget",
|
||||||
|
"duration": "60天,可延期", "income_req": "约 ¥2,000", "approval_time": "5-10 天", "extra": "🏝️ 适合巴厘岛旅居", "difficulty": 25, "difficulty_label": "简单"},
|
||||||
|
{"id": "4", "country": "泰国", "flag": "🇹🇭", "name": "泰国 LTR 签证", "badge": "🆕 新政策", "badge_type": "new",
|
||||||
|
"duration": "10年", "income_req": "$80,000/年", "approval_time": "1-2 个月", "extra": "✈️ 多次入境", "difficulty": 60, "difficulty_label": "中等"},
|
||||||
|
{"id": "5", "country": "墨西哥", "flag": "🇲🇽", "name": "墨西哥 Temporary Resident", "badge": "💰 低成本", "badge_type": "budget",
|
||||||
|
"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": "中等"},
|
||||||
|
]
|
||||||
|
|
||||||
|
FAQS = [
|
||||||
|
{"id": "1", "question": "💰 做数字游民需要多少启动资金?", "order": 1,
|
||||||
|
"answer": "建议准备 3-6 个月的生活费作为缓冲。以东南亚为例,¥15,000-30,000 即可开始。包括机票、首月住宿、签证费用和应急资金。欧洲目的地建议准备 ¥50,000 以上。"},
|
||||||
|
{"id": "2", "question": "📶 如何确保远程工作的网络稳定?", "order": 2,
|
||||||
|
"answer": "选择网络评分高的城市,入住前用 Speedtest 测试。备用方案:本地 SIM 卡热点、随身 WiFi 设备、附近 Co-working Space。推荐携带 USB 网卡和 VPN 作为双保险。"},
|
||||||
|
{"id": "3", "question": "🏥 旅居期间的保险怎么办?", "order": 3,
|
||||||
|
"answer": "推荐 SafetyWing 或 World Nomads 等国际医疗保险,月费约 $40-80,覆盖全球(部分国家除外)。长期旅居者可考虑目的地国家的本地保险,费用更低、报销更方便。"},
|
||||||
|
{"id": "4", "question": "🧾 税务问题如何处理?", "order": 4,
|
||||||
|
"answer": "税务居民身份取决于居住天数(通常 183 天规则)。建议咨询专业税务顾问,了解双重征税协定。很多游民选择税务友好的国家(如葡萄牙、格鲁吉亚)作为基地。"},
|
||||||
|
{"id": "5", "question": "👨👩👧 可以带娃一起做数字游民吗?", "order": 5,
|
||||||
|
"answer": "完全可以!巴厘岛、清迈、里斯本都有成熟的数字游民家庭社区。关键是选择教育资源丰富、医疗条件好的目的地,以及保持稳定的工作节奏,给孩子规律的生活。"},
|
||||||
|
{"id": "6", "question": "🤝 如何快速融入当地游民社区?", "order": 6,
|
||||||
|
"answer": "加入 Nomad List、Facebook 群组和本地 Meetup 活动。入住游民友好的 Co-living 空间,参加每周的 Coworking 社交日。大部分游民社区非常开放,主动打招呼就能结识朋友。"},
|
||||||
|
]
|
||||||
|
|
||||||
|
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},
|
||||||
|
]
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{"id": "1", "emoji": "💼", "name": "远程协作", "description": "Slack · Notion · Figma · Zoom", "tags": ["团队", "设计", "沟通"], "category": "work"},
|
||||||
|
{"id": "2", "emoji": "✈️", "name": "旅行规划", "description": "Skyscanner · Nomad List · SafetyWing", "tags": ["机票", "签证", "保险"], "category": "travel"},
|
||||||
|
{"id": "3", "emoji": "💳", "name": "财务管理", "description": "Wise · Revolut · Xero · 多币种账户", "tags": ["汇款", "记账", "税务"], "category": "finance"},
|
||||||
|
{"id": "4", "emoji": "🤝", "name": "社群网络", "description": "Nomad List · Remote Year · 本地 Meetup", "tags": ["社交", "活动", "合租"], "category": "connect"},
|
||||||
|
{"id": "5", "emoji": "🏥", "name": "健康保障", "description": "SafetyWing · World Nomads · 运动 App", "tags": ["保险", "健身", "心理"], "category": "health"},
|
||||||
|
{"id": "6", "emoji": "📚", "name": "持续学习", "description": "Coursera · Duolingo · 当地语言班", "tags": ["技能", "语言", "文化"], "category": "learn"},
|
||||||
|
]
|
||||||
|
|
||||||
|
BLOG_POSTS = [
|
||||||
|
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "NomadFlow", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
|
||||||
|
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "NomadFlow", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
|
||||||
|
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "NomadFlow", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
BLOG_CONTENT: dict[str, str] = {
|
||||||
|
"chiangmai-guide-2026": """## 为什么选择清迈?
|
||||||
|
|
||||||
|
清迈是公认的全球数字游民之都。低廉的生活成本、完善的 Co-working 生态、友善的本地人和丰富的文化活动,让它成为新手游民的最佳起点。
|
||||||
|
|
||||||
|
## 签证方案
|
||||||
|
|
||||||
|
- **旅游签**:落地签 15 天,或提前办旅游签 60 天
|
||||||
|
- **DTV 签证**:2024 年推出的 Destination Thailand Visa,适合远程工作者
|
||||||
|
- **学生签/精英签**:长期旅居的进阶选择
|
||||||
|
|
||||||
|
## 住宿推荐
|
||||||
|
|
||||||
|
| 区域 | 月租 | 特点 |
|
||||||
|
|------|------|------|
|
||||||
|
| Nimman | ¥2,500-4,000 | 咖啡馆、餐厅、年轻人多 |
|
||||||
|
| Old City | ¥1,500-3,000 | 文化氛围浓,步行可达寺庙 |
|
||||||
|
| Hang Dong | ¥2,000-3,500 | 安静,适合深度工作 |
|
||||||
|
|
||||||
|
## 最佳 Co-working
|
||||||
|
|
||||||
|
1. **Punspace** — Nimman 区经典,日票 ¥60
|
||||||
|
2. **CAMP** — Maya 商场顶楼,免费(消费即可)
|
||||||
|
3. **Hub53** — 安静专业,月票 ¥800
|
||||||
|
|
||||||
|
## 月均预算
|
||||||
|
|
||||||
|
- 住宿:¥2,500
|
||||||
|
- 餐饮:¥1,200
|
||||||
|
- 交通:¥300(租摩托)
|
||||||
|
- Co-working:¥400
|
||||||
|
- 其他:¥400
|
||||||
|
- **合计:约 ¥3,800-4,500**
|
||||||
|
|
||||||
|
## 实用 Tips
|
||||||
|
|
||||||
|
☕ 推荐咖啡馆:Ristr8to(世界冠军)、Graph One Nimman
|
||||||
|
🏍️ 租摩托月费约 ¥400,注意戴头盔
|
||||||
|
📱 推荐 AIS 或 TrueMove 无限流量套餐
|
||||||
|
🤝 每周四有 Nomad Meetup,关注 Facebook 群组""",
|
||||||
|
|
||||||
|
"portugal-d7-visa": """## D7 签证是什么?
|
||||||
|
|
||||||
|
葡萄牙 D7 签证(Passive Income Visa)最初为退休者设计,但因收入要求低、审批相对简单,成为数字游民进入欧盟的最佳通道之一。
|
||||||
|
|
||||||
|
## 申请条件
|
||||||
|
|
||||||
|
- 月收入不低于 **€760**(葡萄牙最低工资)
|
||||||
|
- 银行存款建议 **€9,120+**(12个月生活费)
|
||||||
|
- 无犯罪记录
|
||||||
|
- 葡萄牙本地银行账户
|
||||||
|
- 健康保险
|
||||||
|
|
||||||
|
## 申请流程
|
||||||
|
|
||||||
|
1. **准备材料**(2-4 周)
|
||||||
|
- 护照、照片、收入证明、银行流水
|
||||||
|
- 葡萄牙税号(NIF)
|
||||||
|
- 住宿证明(租房合同或酒店预订)
|
||||||
|
|
||||||
|
2. **递交申请**
|
||||||
|
- 在中国:葡萄牙驻华使馆
|
||||||
|
- 或入境葡萄牙后转居留许可
|
||||||
|
|
||||||
|
3. **等待审批**(3-6 个月)
|
||||||
|
|
||||||
|
4. **登陆葡萄牙**
|
||||||
|
- 领取居留卡
|
||||||
|
- 登记住址
|
||||||
|
|
||||||
|
## 费用预算
|
||||||
|
|
||||||
|
- 签证费:约 €90
|
||||||
|
- 律师费(可选):€500-1,500
|
||||||
|
- NIF + 银行开户:€200-300
|
||||||
|
- 首月生活费:€800-1,200
|
||||||
|
|
||||||
|
## 里斯本生活成本
|
||||||
|
|
||||||
|
- 一居室公寓:€800-1,200/月
|
||||||
|
- Co-working:€150-250/月
|
||||||
|
- 餐饮:€300-500/月
|
||||||
|
- **月均总花费:€1,200-1,800**
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q: D7 可以工作吗?**
|
||||||
|
A: 可以远程为海外雇主工作,但不能在葡萄牙本地公司就职。
|
||||||
|
|
||||||
|
**Q: 5年后能拿护照吗?**
|
||||||
|
A: 满足居住要求后可以申请永居或入籍。""",
|
||||||
|
|
||||||
|
"nomad-tax-basics": """## 1. 什么是税务居民?
|
||||||
|
|
||||||
|
大多数国家用 **183 天规则** 判定税务居民:在一个自然年内居住超过 183 天,即成为该国税务居民,需在该国申报全球收入。
|
||||||
|
|
||||||
|
## 2. 双重征税怎么办?
|
||||||
|
|
||||||
|
如果两个国家都认为你是税务居民,查 **双边税收协定**(DTA)。中国已与 100+ 国家签署 DTA,可避免重复缴税。
|
||||||
|
|
||||||
|
## 3. 数字游民常见税务策略
|
||||||
|
|
||||||
|
| 策略 | 说明 | 适合人群 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 零税务居民 | 不在任何国家住满 183 天 | 短期旅居者 |
|
||||||
|
| 税务友好国 | 葡萄牙 NHR、格鲁吉亚 1% 税 | 长期旅居者 |
|
||||||
|
| 原籍国申报 | 回国期间申报 | 兼职游民 |
|
||||||
|
|
||||||
|
## 4. 中国税务居民注意
|
||||||
|
|
||||||
|
- 中国公民默认是中国税务居民(全球征税)
|
||||||
|
- 海外收入也需申报(可抵免境外已缴税)
|
||||||
|
- 建议咨询专业税务师
|
||||||
|
|
||||||
|
## 5. 实用建议
|
||||||
|
|
||||||
|
1. 📋 记录每个国家的入境/出境日期
|
||||||
|
2. 🧾 保留所有收入凭证和银行流水
|
||||||
|
3. 🏦 使用 Wise 等工具便于跨境汇款记录
|
||||||
|
4. 👨💼 收入超过一定金额建议聘请税务顾问
|
||||||
|
5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""",
|
||||||
|
}
|
||||||
|
|
||||||
|
TICKER_MESSAGES = [
|
||||||
|
"🌴 小林 刚刚抵达清迈",
|
||||||
|
"💻 Marco 在里斯本完成了 Sprint",
|
||||||
|
"✈️ 今日新增 127 位游民出发",
|
||||||
|
"🏝️ 巴厘岛 Co-working 今日 89% 满座",
|
||||||
|
"📶 清迈平均网速 95Mbps",
|
||||||
|
"🌅 东京远程工作者满意度 9.1",
|
||||||
|
]
|
||||||
31
backend/app/main.py
Normal file
31
backend/app/main.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.routers import api, auth
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="NomadFlow API",
|
||||||
|
description="数字游民旅居平台后端 API",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api.router, prefix="/api/v1")
|
||||||
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"name": "NomadFlow API",
|
||||||
|
"docs": "/docs",
|
||||||
|
"health": "/api/v1/health",
|
||||||
|
}
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
215
backend/app/routers/api.py
Normal file
215
backend/app/routers/api.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from app.schemas import (
|
||||||
|
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
|
||||||
|
CostCalculatorResponse, Destination, FAQ, SearchResult, StatsResponse,
|
||||||
|
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
|
||||||
|
)
|
||||||
|
from app.services.pocketbase import pb
|
||||||
|
from app.data import mock_data
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health():
|
||||||
|
pb_ok = await pb.is_available()
|
||||||
|
return {"status": "ok", "pocketbase": "connected" if pb_ok else "fallback"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=StatsResponse)
|
||||||
|
async def get_stats():
|
||||||
|
dests = await pb.get_destinations()
|
||||||
|
avg = sum(d["cost"] for d in dests) // len(dests) if dests else 42
|
||||||
|
return StatsResponse(
|
||||||
|
countries=195,
|
||||||
|
avg_cost=avg // 100,
|
||||||
|
satisfaction=87,
|
||||||
|
total_nomads="35.6M",
|
||||||
|
active_today=127,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ticker")
|
||||||
|
async def get_ticker():
|
||||||
|
return {"messages": mock_data.TICKER_MESSAGES}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/destinations", response_model=list[Destination])
|
||||||
|
async def list_destinations(
|
||||||
|
region: str | None = Query(None),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
sort: str = Query("rating"),
|
||||||
|
):
|
||||||
|
data = await pb.get_destinations(region=region, search=search, sort=sort)
|
||||||
|
return [Destination(**d) for d in data]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/destinations/{slug}", response_model=Destination)
|
||||||
|
async def get_destination(slug: str):
|
||||||
|
data = await pb.get_destination(slug)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(404, "目的地不存在")
|
||||||
|
return Destination(**data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/destinations/compare", response_model=list[Destination])
|
||||||
|
async def compare_destinations(body: CompareRequest):
|
||||||
|
all_dests = await pb.get_destinations()
|
||||||
|
result = [d for d in all_dests if d["slug"] in body.slugs]
|
||||||
|
if len(result) < 2:
|
||||||
|
raise HTTPException(400, "至少需要 2 个有效目的地")
|
||||||
|
return [Destination(**d) for d in result]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visas", response_model=list[Visa])
|
||||||
|
async def list_visas():
|
||||||
|
return [Visa(**v) for v in await pb.get_visas()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/faqs", response_model=list[FAQ])
|
||||||
|
async def list_faqs():
|
||||||
|
return [FAQ(**f) for f in await pb.get_faqs()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/testimonials", response_model=list[Testimonial])
|
||||||
|
async def list_testimonials():
|
||||||
|
return [Testimonial(**t) for t in await pb.get_testimonials()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools", response_model=list[Tool])
|
||||||
|
async def list_tools():
|
||||||
|
return [Tool(**t) for t in await pb.get_tools()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/blog", response_model=list[BlogPost])
|
||||||
|
async def list_blog():
|
||||||
|
return [BlogPost(**b) for b in await pb.get_blog_posts()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/blog/{slug}", response_model=BlogPostDetail)
|
||||||
|
async def get_blog(slug: str):
|
||||||
|
post = await pb.get_blog_post(slug)
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(404, "文章不存在")
|
||||||
|
return BlogPostDetail(**post)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe", response_model=SubscribeResponse)
|
||||||
|
async def subscribe(body: SubscribeRequest):
|
||||||
|
ok, msg = await pb.subscribe(body.email)
|
||||||
|
return SubscribeResponse(success=ok, message=msg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calculator", response_model=CostCalculatorResponse)
|
||||||
|
async def cost_calculator(body: CostCalculatorRequest):
|
||||||
|
dest = await pb.get_destination(body.destination_slug)
|
||||||
|
if not dest:
|
||||||
|
raise HTTPException(404, "目的地不存在")
|
||||||
|
|
||||||
|
multipliers = {"budget": 0.7, "mid": 1.0, "premium": 1.5}
|
||||||
|
m = multipliers.get(body.housing, 1.0)
|
||||||
|
base = dest["cost"]
|
||||||
|
|
||||||
|
breakdown = {
|
||||||
|
"🏠 住宿": int(base * 0.35 * m),
|
||||||
|
"🍜 餐饮": int(base * 0.25 * m),
|
||||||
|
"🚗 交通": int(base * 0.10),
|
||||||
|
"📶 网络/办公": int(base * 0.10),
|
||||||
|
"🎉 娱乐社交": int(base * 0.12 * m),
|
||||||
|
"🏥 保险医疗": int(base * 0.08),
|
||||||
|
}
|
||||||
|
per_month = sum(breakdown.values())
|
||||||
|
total = per_month * body.months
|
||||||
|
|
||||||
|
return CostCalculatorResponse(
|
||||||
|
destination=f"{dest['name']}, {dest['country']}",
|
||||||
|
emoji=dest["emoji"],
|
||||||
|
months=body.months,
|
||||||
|
breakdown=breakdown,
|
||||||
|
total=total,
|
||||||
|
per_month=per_month,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/charts/cost", response_model=ChartData)
|
||||||
|
async def chart_cost():
|
||||||
|
dests = await pb.get_destinations()
|
||||||
|
dests = sorted(dests, key=lambda x: x["cost"])
|
||||||
|
return ChartData(
|
||||||
|
labels=[f"{d['name']} {d['emoji']}" for d in dests],
|
||||||
|
datasets=[{
|
||||||
|
"label": "月生活费 (元)",
|
||||||
|
"data": [d["cost"] for d in dests],
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/charts/speed", response_model=ChartData)
|
||||||
|
async def chart_speed():
|
||||||
|
dests = await pb.get_destinations()
|
||||||
|
dests = sorted(dests, key=lambda x: x["speed"], reverse=True)
|
||||||
|
return ChartData(
|
||||||
|
labels=[f"{d['name']} {d['speed']}Mbps" for d in dests],
|
||||||
|
datasets=[{"data": [d["speed"] for d in dests]}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/charts/growth", response_model=ChartData)
|
||||||
|
async def chart_growth():
|
||||||
|
return ChartData(
|
||||||
|
labels=["2019", "2020", "2021", "2022", "2023", "2024", "2025", "2026"],
|
||||||
|
datasets=[{
|
||||||
|
"label": "全球数字游民 (百万人)",
|
||||||
|
"data": [7.3, 10.9, 15.5, 24.0, 28.5, 31.2, 33.8, 35.6],
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/charts/radar", response_model=ChartData)
|
||||||
|
async def chart_radar():
|
||||||
|
return ChartData(
|
||||||
|
labels=["生活成本", "网络速度", "安全性", "社群活跃", "气候环境", "签证便利"],
|
||||||
|
datasets=[
|
||||||
|
{"label": "清迈", "data": [95, 80, 85, 95, 70, 90]},
|
||||||
|
{"label": "里斯本", "data": [60, 90, 92, 80, 85, 95]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=list[SearchResult])
|
||||||
|
async def global_search(q: str = Query(..., min_length=1)):
|
||||||
|
query = q.lower().strip()
|
||||||
|
results: list[SearchResult] = []
|
||||||
|
|
||||||
|
for d in await pb.get_destinations(search=query):
|
||||||
|
results.append(SearchResult(
|
||||||
|
type="destination",
|
||||||
|
title=f"{d['name']}, {d['country']}",
|
||||||
|
subtitle=f"¥{d['cost']}/月 · ⭐{d['rating']}",
|
||||||
|
emoji=d["emoji"],
|
||||||
|
url=f"/destinations/{d['slug']}",
|
||||||
|
))
|
||||||
|
|
||||||
|
for b in await pb.get_blog_posts():
|
||||||
|
if query in b["title"].lower() or query in b.get("excerpt", "").lower():
|
||||||
|
results.append(SearchResult(
|
||||||
|
type="blog", title=b["title"], subtitle=b["excerpt"][:60],
|
||||||
|
emoji=b["emoji"], url=f"/blog/{b['slug']}",
|
||||||
|
))
|
||||||
|
|
||||||
|
for v in await pb.get_visas():
|
||||||
|
if query in v["name"].lower() or query in v["country"].lower():
|
||||||
|
results.append(SearchResult(
|
||||||
|
type="visa", title=v["name"], subtitle=v["country"],
|
||||||
|
emoji=v["flag"], url="/#visa",
|
||||||
|
))
|
||||||
|
|
||||||
|
for f in await pb.get_faqs():
|
||||||
|
if query in f["question"].lower() or query in f["answer"].lower():
|
||||||
|
results.append(SearchResult(
|
||||||
|
type="faq", title=f["question"], subtitle=f["answer"][:60],
|
||||||
|
emoji="❓", url="/#faq",
|
||||||
|
))
|
||||||
|
|
||||||
|
return results[:12]
|
||||||
103
backend/app/routers/auth.py
Normal file
103
backend/app/routers/auth.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
from fastapi import APIRouter, Header, HTTPException
|
||||||
|
|
||||||
|
from app.schemas import (
|
||||||
|
AuthLogin, AuthRegister, AuthResponse, Destination,
|
||||||
|
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
|
||||||
|
)
|
||||||
|
from app.services.auth import (
|
||||||
|
DEMO_TOKEN, get_favorites, get_user_by_token,
|
||||||
|
login_user, register_user, toggle_favorite,
|
||||||
|
)
|
||||||
|
from app.services.pocketbase import pb
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=AuthResponse)
|
||||||
|
async def register(body: AuthRegister):
|
||||||
|
result = register_user(body.email, body.password, body.name)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(400, "该邮箱已注册")
|
||||||
|
return AuthResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=AuthResponse)
|
||||||
|
async def login(body: AuthLogin):
|
||||||
|
result = login_user(body.email, body.password)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(401, "邮箱或密码错误")
|
||||||
|
return AuthResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserProfile)
|
||||||
|
async def me(authorization: str | None = Header(None)):
|
||||||
|
token = _extract_token(authorization)
|
||||||
|
user = get_user_by_token(token)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
return UserProfile(**user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo", response_model=AuthResponse)
|
||||||
|
async def demo_login():
|
||||||
|
"""一键体验演示账号"""
|
||||||
|
user = get_user_by_token(DEMO_TOKEN)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(500, "演示账号不可用")
|
||||||
|
return AuthResponse(token=DEMO_TOKEN, user=UserProfile(**user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favorites", response_model=FavoriteResponse)
|
||||||
|
async def list_favorites(authorization: str | None = Header(None)):
|
||||||
|
token = _extract_token(authorization)
|
||||||
|
if not get_user_by_token(token):
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
return FavoriteResponse(slugs=get_favorites(token))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favorites/detail", response_model=list[Destination])
|
||||||
|
async def favorite_details(authorization: str | None = Header(None)):
|
||||||
|
token = _extract_token(authorization)
|
||||||
|
user = get_user_by_token(token)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
slugs = get_favorites(token)
|
||||||
|
all_dests = await pb.get_destinations()
|
||||||
|
return [Destination(**d) for d in all_dests if d["slug"] in slugs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile/stats", response_model=ProfileStats)
|
||||||
|
async def profile_stats(authorization: str | None = Header(None)):
|
||||||
|
token = _extract_token(authorization)
|
||||||
|
user = get_user_by_token(token)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
favs = get_favorites(token)
|
||||||
|
levels = [(0, "🌱 新手游民"), (1, "🎒 背包客"), (2, "✈️ 飞行游民"), (3, "🌍 环球游民")]
|
||||||
|
level = levels[min(len(favs), 3)][1]
|
||||||
|
return ProfileStats(
|
||||||
|
favorites_count=len(favs),
|
||||||
|
destinations_explored=len(favs),
|
||||||
|
member_since="2026",
|
||||||
|
nomad_level=level,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/favorites", response_model=FavoriteResponse)
|
||||||
|
async def add_favorite(body: FavoriteRequest, authorization: str | None = Header(None)):
|
||||||
|
token = _extract_token(authorization)
|
||||||
|
if not get_user_by_token(token):
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
dest = await pb.get_destination(body.destination_slug)
|
||||||
|
if not dest:
|
||||||
|
raise HTTPException(404, "目的地不存在")
|
||||||
|
slugs = toggle_favorite(token, body.destination_slug)
|
||||||
|
return FavoriteResponse(slugs=slugs)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token(authorization: str | None) -> str:
|
||||||
|
if not authorization:
|
||||||
|
return ""
|
||||||
|
if authorization.startswith("Bearer "):
|
||||||
|
return authorization[7:]
|
||||||
|
return authorization
|
||||||
164
backend/app/schemas.py
Normal file
164
backend/app/schemas.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Destination(BaseModel):
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
country: str
|
||||||
|
emoji: str
|
||||||
|
tag: str
|
||||||
|
description: str
|
||||||
|
region: str
|
||||||
|
cost: int
|
||||||
|
speed: int
|
||||||
|
temperature: int
|
||||||
|
rating: float
|
||||||
|
hue: int = 170
|
||||||
|
nomads_count: str = "5,000+"
|
||||||
|
highlights: list[str] = []
|
||||||
|
map_x: float = 0
|
||||||
|
map_y: float = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Visa(BaseModel):
|
||||||
|
id: str
|
||||||
|
country: str
|
||||||
|
flag: str
|
||||||
|
name: str
|
||||||
|
badge: str
|
||||||
|
badge_type: str
|
||||||
|
duration: str
|
||||||
|
income_req: str
|
||||||
|
approval_time: str
|
||||||
|
extra: str
|
||||||
|
difficulty: int
|
||||||
|
difficulty_label: str
|
||||||
|
|
||||||
|
|
||||||
|
class FAQ(BaseModel):
|
||||||
|
id: str
|
||||||
|
question: str
|
||||||
|
answer: str
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Testimonial(BaseModel):
|
||||||
|
id: str
|
||||||
|
avatar: str
|
||||||
|
content: str
|
||||||
|
author: str
|
||||||
|
role: str
|
||||||
|
rating: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class Tool(BaseModel):
|
||||||
|
id: str
|
||||||
|
emoji: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
tags: list[str]
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class CostCalculatorRequest(BaseModel):
|
||||||
|
destination_slug: str
|
||||||
|
housing: str = "mid" # budget | mid | premium
|
||||||
|
months: int = Field(default=1, ge=1, le=24)
|
||||||
|
|
||||||
|
|
||||||
|
class CostCalculatorResponse(BaseModel):
|
||||||
|
destination: str
|
||||||
|
emoji: str
|
||||||
|
months: int
|
||||||
|
breakdown: dict[str, int]
|
||||||
|
total: int
|
||||||
|
per_month: int
|
||||||
|
|
||||||
|
|
||||||
|
class CompareRequest(BaseModel):
|
||||||
|
slugs: list[str] = Field(..., min_length=2, max_length=4)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsResponse(BaseModel):
|
||||||
|
countries: int
|
||||||
|
avg_cost: int
|
||||||
|
satisfaction: int
|
||||||
|
total_nomads: str
|
||||||
|
active_today: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChartData(BaseModel):
|
||||||
|
labels: list[str]
|
||||||
|
datasets: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class BlogPost(BaseModel):
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
excerpt: str
|
||||||
|
emoji: str
|
||||||
|
author: str
|
||||||
|
published_at: str
|
||||||
|
read_time: int
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class BlogPostDetail(BlogPost):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRegister(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=6)
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
avatar: str = "🧑💻"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
user: UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteRequest(BaseModel):
|
||||||
|
destination_slug: str
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteResponse(BaseModel):
|
||||||
|
slugs: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileStats(BaseModel):
|
||||||
|
favorites_count: int
|
||||||
|
destinations_explored: int
|
||||||
|
member_since: str
|
||||||
|
nomad_level: str
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResult(BaseModel):
|
||||||
|
type: str # destination | blog | visa | faq
|
||||||
|
title: str
|
||||||
|
subtitle: str
|
||||||
|
emoji: str
|
||||||
|
url: str
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
83
backend/app/services/auth.py
Normal file
83
backend/app/services/auth.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""Simple auth service with in-memory fallback when PocketBase users unavailable."""
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.data import mock_data
|
||||||
|
|
||||||
|
# In-memory store for demo mode
|
||||||
|
_users: dict[str, dict] = {}
|
||||||
|
_sessions: dict[str, str] = {} # token -> user_id
|
||||||
|
_favorites: dict[str, list[str]] = {} # user_id -> [slugs]
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_password(password: str) -> str:
|
||||||
|
return hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def register_user(email: str, password: str, name: str) -> dict[str, Any] | None:
|
||||||
|
if email in _users:
|
||||||
|
return None
|
||||||
|
uid = secrets.token_hex(8)
|
||||||
|
_users[email] = {
|
||||||
|
"id": uid,
|
||||||
|
"email": email,
|
||||||
|
"password_hash": _hash_password(password),
|
||||||
|
"name": name,
|
||||||
|
"avatar": "🧑💻",
|
||||||
|
}
|
||||||
|
_favorites[uid] = []
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
_sessions[token] = uid
|
||||||
|
return {"token": token, "user": _user_profile(_users[email])}
|
||||||
|
|
||||||
|
|
||||||
|
def login_user(email: str, password: str) -> dict[str, Any] | None:
|
||||||
|
user = _users.get(email)
|
||||||
|
if not user or user["password_hash"] != _hash_password(password):
|
||||||
|
return None
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
_sessions[token] = user["id"]
|
||||||
|
return {"token": token, "user": _user_profile(user)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_token(token: str) -> dict | None:
|
||||||
|
uid = _sessions.get(token)
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
for user in _users.values():
|
||||||
|
if user["id"] == uid:
|
||||||
|
return _user_profile(user)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_favorites(token: str) -> list[str]:
|
||||||
|
uid = _sessions.get(token)
|
||||||
|
if not uid:
|
||||||
|
return []
|
||||||
|
return _favorites.get(uid, [])
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_favorite(token: str, slug: str) -> list[str]:
|
||||||
|
uid = _sessions.get(token)
|
||||||
|
if not uid:
|
||||||
|
return []
|
||||||
|
favs = _favorites.setdefault(uid, [])
|
||||||
|
if slug in favs:
|
||||||
|
favs.remove(slug)
|
||||||
|
else:
|
||||||
|
favs.append(slug)
|
||||||
|
return favs
|
||||||
|
|
||||||
|
|
||||||
|
def _user_profile(user: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"id": user["id"],
|
||||||
|
"email": user["email"],
|
||||||
|
"name": user["name"],
|
||||||
|
"avatar": user.get("avatar", "🧑💻"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Demo account
|
||||||
|
_demo = register_user("demo@nomadflow.io", "demo123", "演示用户")
|
||||||
|
DEMO_TOKEN = _demo["token"] if _demo else ""
|
||||||
183
backend/app/services/pocketbase.py
Normal file
183
backend/app/services/pocketbase.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import httpx
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.data import mock_data
|
||||||
|
|
||||||
|
|
||||||
|
class PocketBaseService:
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = settings.pocketbase_url.rstrip("/")
|
||||||
|
self._token: str | None = None
|
||||||
|
self._available: bool | None = None
|
||||||
|
|
||||||
|
async def is_available(self) -> bool:
|
||||||
|
if self._available is not None:
|
||||||
|
return self._available
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=1.0) as client:
|
||||||
|
r = await client.get(f"{self.base_url}/api/health")
|
||||||
|
self._available = r.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
self._available = False
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
async def _auth(self, client: httpx.AsyncClient) -> bool:
|
||||||
|
if self._token:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base_url}/api/admins/auth-with-password",
|
||||||
|
json={
|
||||||
|
"identity": settings.pocketbase_admin_email,
|
||||||
|
"password": settings.pocketbase_admin_password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
self._token = r.json().get("token")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def list_records(self, collection: str, sort: str = "") -> list[dict[str, Any]]:
|
||||||
|
if not await self.is_available():
|
||||||
|
return []
|
||||||
|
|
||||||
|
params: dict[str, str] = {"perPage": "200"}
|
||||||
|
if sort:
|
||||||
|
params["sort"] = sort
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
headers = {}
|
||||||
|
if await self._auth(client):
|
||||||
|
headers["Authorization"] = self._token or ""
|
||||||
|
|
||||||
|
r = await client.get(
|
||||||
|
f"{self.base_url}/api/collections/{collection}/records",
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
items = r.json().get("items", [])
|
||||||
|
return items if items else []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def create_record(self, collection: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
if not await self.is_available():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
headers = {}
|
||||||
|
if await self._auth(client):
|
||||||
|
headers["Authorization"] = self._token or ""
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base_url}/api/collections/{collection}/records",
|
||||||
|
json=data,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_destinations(
|
||||||
|
self, region: str | None = None, search: str | None = None, sort: str = "rating"
|
||||||
|
) -> list[dict]:
|
||||||
|
records = await self.list_records("destinations", sort=f"-{sort}" if sort != "cost" else "cost")
|
||||||
|
if not records:
|
||||||
|
records = mock_data.DESTINATIONS
|
||||||
|
else:
|
||||||
|
records = [self._map_destination(r) for r in records]
|
||||||
|
|
||||||
|
if region and region != "all":
|
||||||
|
records = [d for d in records if d.get("region") == region]
|
||||||
|
if search:
|
||||||
|
q = search.lower()
|
||||||
|
records = [
|
||||||
|
d for d in records
|
||||||
|
if q in d.get("name", "").lower() or q in d.get("country", "").lower()
|
||||||
|
]
|
||||||
|
return self._sort_destinations(records, sort)
|
||||||
|
|
||||||
|
async def get_destination(self, slug: str) -> dict | None:
|
||||||
|
records = await self.get_destinations()
|
||||||
|
return next((d for d in records if d.get("slug") == slug), None)
|
||||||
|
|
||||||
|
async def get_visas(self) -> list[dict]:
|
||||||
|
records = await self.list_records("visas", sort="difficulty")
|
||||||
|
return records if records else mock_data.VISAS
|
||||||
|
|
||||||
|
async def get_faqs(self) -> list[dict]:
|
||||||
|
records = await self.list_records("faqs", sort="order")
|
||||||
|
return records if records else mock_data.FAQS
|
||||||
|
|
||||||
|
async def get_testimonials(self) -> list[dict]:
|
||||||
|
records = await self.list_records("testimonials")
|
||||||
|
return records if records else mock_data.TESTIMONIALS
|
||||||
|
|
||||||
|
async def get_tools(self) -> list[dict]:
|
||||||
|
records = await self.list_records("tools")
|
||||||
|
return records if records else mock_data.TOOLS
|
||||||
|
|
||||||
|
async def get_blog_posts(self) -> list[dict]:
|
||||||
|
records = await self.list_records("blog_posts", sort="-published_at")
|
||||||
|
return records if records else mock_data.BLOG_POSTS
|
||||||
|
|
||||||
|
async def get_blog_post(self, slug: str) -> dict | None:
|
||||||
|
posts = await self.get_blog_posts()
|
||||||
|
post = next((p for p in posts if p.get("slug") == slug), None)
|
||||||
|
if not post:
|
||||||
|
return None
|
||||||
|
content = post.get("content") or mock_data.BLOG_CONTENT.get(slug, "")
|
||||||
|
return {**post, "content": content}
|
||||||
|
|
||||||
|
async def subscribe(self, email: str) -> tuple[bool, str]:
|
||||||
|
existing = await self.list_records("subscriptions")
|
||||||
|
if any(s.get("email") == email for s in existing):
|
||||||
|
return True, "你已经订阅过了,欢迎回来!"
|
||||||
|
|
||||||
|
result = await self.create_record("subscriptions", {"email": email})
|
||||||
|
if result:
|
||||||
|
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉"
|
||||||
|
# fallback: always succeed in demo mode
|
||||||
|
return True, "订阅成功!欢迎加入 NomadFlow 社区 🎉"
|
||||||
|
|
||||||
|
def _map_destination(self, r: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"id": r.get("id", ""),
|
||||||
|
"slug": r.get("slug", ""),
|
||||||
|
"name": r.get("name", ""),
|
||||||
|
"country": r.get("country", ""),
|
||||||
|
"emoji": r.get("emoji", ""),
|
||||||
|
"tag": r.get("tag", ""),
|
||||||
|
"description": r.get("description", ""),
|
||||||
|
"region": r.get("region", ""),
|
||||||
|
"cost": r.get("cost", 0),
|
||||||
|
"speed": r.get("speed", 0),
|
||||||
|
"temperature": r.get("temperature", 0),
|
||||||
|
"rating": r.get("rating", 0),
|
||||||
|
"hue": r.get("hue", 170),
|
||||||
|
"nomads_count": r.get("nomads_count", ""),
|
||||||
|
"highlights": r.get("highlights", []),
|
||||||
|
"map_x": r.get("map_x", 0),
|
||||||
|
"map_y": r.get("map_y", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sort_destinations(self, records: list[dict], sort: str) -> list[dict]:
|
||||||
|
if sort == "cost-asc":
|
||||||
|
return sorted(records, key=lambda x: x.get("cost", 0))
|
||||||
|
if sort == "cost-desc":
|
||||||
|
return sorted(records, key=lambda x: x.get("cost", 0), reverse=True)
|
||||||
|
if sort == "speed":
|
||||||
|
return sorted(records, key=lambda x: x.get("speed", 0), reverse=True)
|
||||||
|
return sorted(records, key=lambda x: x.get("rating", 0), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
pb = PocketBaseService()
|
||||||
8
backend/railway.toml
Normal file
8
backend/railway.toml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[build]
|
||||||
|
builder = "DOCKERFILE"
|
||||||
|
dockerfilePath = "Dockerfile"
|
||||||
|
|
||||||
|
[deploy]
|
||||||
|
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port $PORT"
|
||||||
|
healthcheckPath = "/api/v1/health"
|
||||||
|
restartPolicyType = "ON_FAILURE"
|
||||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
httpx==0.28.1
|
||||||
|
pydantic[email]==2.10.4
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
email-validator==2.2.0
|
||||||
142
backend/seed_pocketbase.py
Normal file
142
backend/seed_pocketbase.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Seed PocketBase collections with NomadFlow data.
|
||||||
|
Run after PocketBase is started: python seed_pocketbase.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from app.config import settings
|
||||||
|
from app.data.mock_data import DESTINATIONS, VISAS, FAQS, TESTIMONIALS, TOOLS, BLOG_POSTS
|
||||||
|
|
||||||
|
COLLECTIONS = {
|
||||||
|
"destinations": {
|
||||||
|
"schema": [
|
||||||
|
{"name": "slug", "type": "text", "required": True},
|
||||||
|
{"name": "name", "type": "text", "required": True},
|
||||||
|
{"name": "country", "type": "text"},
|
||||||
|
{"name": "emoji", "type": "text"},
|
||||||
|
{"name": "tag", "type": "text"},
|
||||||
|
{"name": "description", "type": "text"},
|
||||||
|
{"name": "region", "type": "text"},
|
||||||
|
{"name": "cost", "type": "number"},
|
||||||
|
{"name": "speed", "type": "number"},
|
||||||
|
{"name": "temperature", "type": "number"},
|
||||||
|
{"name": "rating", "type": "number"},
|
||||||
|
{"name": "hue", "type": "number"},
|
||||||
|
{"name": "nomads_count", "type": "text"},
|
||||||
|
{"name": "highlights", "type": "json"},
|
||||||
|
{"name": "map_x", "type": "number"},
|
||||||
|
{"name": "map_y", "type": "number"},
|
||||||
|
],
|
||||||
|
"data": DESTINATIONS,
|
||||||
|
"key": "slug",
|
||||||
|
},
|
||||||
|
"visas": {
|
||||||
|
"schema": [
|
||||||
|
{"name": "country", "type": "text"}, {"name": "flag", "type": "text"},
|
||||||
|
{"name": "name", "type": "text"}, {"name": "badge", "type": "text"},
|
||||||
|
{"name": "badge_type", "type": "text"}, {"name": "duration", "type": "text"},
|
||||||
|
{"name": "income_req", "type": "text"}, {"name": "approval_time", "type": "text"},
|
||||||
|
{"name": "extra", "type": "text"}, {"name": "difficulty", "type": "number"},
|
||||||
|
{"name": "difficulty_label", "type": "text"},
|
||||||
|
],
|
||||||
|
"data": VISAS,
|
||||||
|
"key": "name",
|
||||||
|
},
|
||||||
|
"faqs": {
|
||||||
|
"schema": [
|
||||||
|
{"name": "question", "type": "text"}, {"name": "answer", "type": "text"},
|
||||||
|
{"name": "order", "type": "number"},
|
||||||
|
],
|
||||||
|
"data": FAQS,
|
||||||
|
"key": "question",
|
||||||
|
},
|
||||||
|
"testimonials": {
|
||||||
|
"schema": [
|
||||||
|
{"name": "avatar", "type": "text"}, {"name": "content", "type": "text"},
|
||||||
|
{"name": "author", "type": "text"}, {"name": "role", "type": "text"},
|
||||||
|
{"name": "rating", "type": "number"},
|
||||||
|
],
|
||||||
|
"data": TESTIMONIALS,
|
||||||
|
"key": "author",
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"schema": [
|
||||||
|
{"name": "emoji", "type": "text"}, {"name": "name", "type": "text"},
|
||||||
|
{"name": "description", "type": "text"}, {"name": "tags", "type": "json"},
|
||||||
|
{"name": "category", "type": "text"},
|
||||||
|
],
|
||||||
|
"data": TOOLS,
|
||||||
|
"key": "name",
|
||||||
|
},
|
||||||
|
"blog_posts": {
|
||||||
|
"schema": [
|
||||||
|
{"name": "slug", "type": "text"}, {"name": "title", "type": "text"},
|
||||||
|
{"name": "excerpt", "type": "text"}, {"name": "content", "type": "text"},
|
||||||
|
{"name": "emoji", "type": "text"}, {"name": "author", "type": "text"},
|
||||||
|
{"name": "published_at", "type": "text"}, {"name": "read_time", "type": "number"},
|
||||||
|
{"name": "tags", "type": "json"},
|
||||||
|
],
|
||||||
|
"data": [{**p, "content": mock_data.BLOG_CONTENT.get(p["slug"], "")} for p in BLOG_POSTS],
|
||||||
|
"key": "slug",
|
||||||
|
},
|
||||||
|
"subscriptions": {
|
||||||
|
"schema": [{"name": "email", "type": "email", "required": True}],
|
||||||
|
"data": [],
|
||||||
|
"key": "email",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
base = settings.pocketbase_url.rstrip("/")
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
# Auth
|
||||||
|
auth = await client.post(
|
||||||
|
f"{base}/api/admins/auth-with-password",
|
||||||
|
json={"identity": settings.pocketbase_admin_email, "password": settings.pocketbase_admin_password},
|
||||||
|
)
|
||||||
|
if auth.status_code != 200:
|
||||||
|
print("❌ PocketBase 认证失败,请先在 Admin UI 创建管理员账号")
|
||||||
|
print(f" 访问 {base}/_/ 创建账号: {settings.pocketbase_admin_email}")
|
||||||
|
return
|
||||||
|
|
||||||
|
token = auth.json()["token"]
|
||||||
|
headers = {"Authorization": token}
|
||||||
|
|
||||||
|
for coll_name, config in COLLECTIONS.items():
|
||||||
|
# Check if collection exists
|
||||||
|
check = await client.get(f"{base}/api/collections/{coll_name}", headers=headers)
|
||||||
|
if check.status_code == 404:
|
||||||
|
body = {
|
||||||
|
"name": coll_name,
|
||||||
|
"type": "base",
|
||||||
|
"schema": config["schema"],
|
||||||
|
}
|
||||||
|
create = await client.post(f"{base}/api/collections", json=body, headers=headers)
|
||||||
|
if create.status_code in (200, 201):
|
||||||
|
print(f"✅ 创建集合: {coll_name}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ 集合 {coll_name} 创建失败: {create.text}")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
print(f"📦 集合已存在: {coll_name}")
|
||||||
|
|
||||||
|
# Seed records
|
||||||
|
for item in config["data"]:
|
||||||
|
payload = {k: v for k, v in item.items() if k != "id"}
|
||||||
|
r = await client.post(
|
||||||
|
f"{base}/api/collections/{coll_name}/records",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
print(f" + {item.get(config['key'], '')}")
|
||||||
|
elif "already" in r.text.lower() or r.status_code == 400:
|
||||||
|
print(f" ~ {item.get(config['key'], '')} (已存在)")
|
||||||
|
|
||||||
|
print("\n🎉 数据种子完成!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1912
css/style.css
Normal file
1912
css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
services:
|
||||||
|
pocketbase:
|
||||||
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
|
container_name: nomadflow-pb
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
volumes:
|
||||||
|
- ./pocketbase/pb_data:/pb_data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: nomadflow-api
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- POCKETBASE_URL=http://pocketbase:8090
|
||||||
|
- POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io
|
||||||
|
- POCKETBASE_ADMIN_PASSWORD=admin123456
|
||||||
|
- CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: nomadflow-web
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
1
frontend/.env.local.example
Normal file
1
frontend/.env.local.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
9
frontend/AGENTS.md
Normal file
9
frontend/AGENTS.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
1
frontend/CLAUDE.md
Normal file
1
frontend/CLAUDE.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
26
frontend/Dockerfile
Normal file
26
frontend/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000 HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
15
frontend/next.config.ts
Normal file
15
frontend/next.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${process.env.API_URL || "http://localhost:8000"}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6234
frontend/package-lock.json
generated
Normal file
6234
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.3.3",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.3.3",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
56
frontend/src/app/blog/[slug]/page.tsx
Normal file
56
frontend/src/app/blog/[slug]/page.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import BlogReadingProgress from "@/components/BlogReadingProgress";
|
||||||
|
|
||||||
|
function renderMarkdown(content: string) {
|
||||||
|
return content.split("\n").map((line, i) => {
|
||||||
|
if (line.startsWith("## ")) return <h2 key={i}>{line.slice(3)}</h2>;
|
||||||
|
if (line.startsWith("### ")) return <h3 key={i}>{line.slice(4)}</h3>;
|
||||||
|
if (line.startsWith("| ")) return null; // skip table rows for simplicity
|
||||||
|
if (line.startsWith("- ")) return <li key={i}>{line.slice(2)}</li>;
|
||||||
|
if (line.match(/^\d+\. /)) return <li key={i}>{line.replace(/^\d+\. /, "")}</li>;
|
||||||
|
if (line.trim() === "") return <br key={i} />;
|
||||||
|
if (line.startsWith("**Q:")) return <p key={i}><strong>{line}</strong></p>;
|
||||||
|
return <p key={i}>{line}</p>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
let post;
|
||||||
|
try {
|
||||||
|
post = await api.getBlogPost(slug);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell>
|
||||||
|
<BlogReadingProgress />
|
||||||
|
<div className="detail-page">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/#blog">← 返回博客</Link>
|
||||||
|
</nav>
|
||||||
|
<article className="blog-detail">
|
||||||
|
<div className="blog-detail-header">
|
||||||
|
<span className="blog-detail-emoji">{post.emoji}</span>
|
||||||
|
<div className="blog-meta">
|
||||||
|
<span>📅 {post.published_at}</span>
|
||||||
|
<span>⏱️ {post.read_time} 分钟阅读</span>
|
||||||
|
<span>✍️ {post.author}</span>
|
||||||
|
</div>
|
||||||
|
<h1>{post.title}</h1>
|
||||||
|
<div className="blog-tags">
|
||||||
|
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="blog-detail-content">
|
||||||
|
{renderMarkdown(post.content)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/app/destinations/[slug]/page.tsx
Normal file
56
frontend/src/app/destinations/[slug]/page.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import FavoriteButton from "@/components/FavoriteButton";
|
||||||
|
import DestinationDetailClient from "@/components/DestinationDetailClient";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
|
||||||
|
export default async function DestinationPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
let dest;
|
||||||
|
let allDestinations;
|
||||||
|
try {
|
||||||
|
[dest, allDestinations] = await Promise.all([
|
||||||
|
api.getDestination(slug),
|
||||||
|
api.getDestinations(),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell>
|
||||||
|
<div className="detail-page">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/#destinations">← 返回目的地</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="dest-detail">
|
||||||
|
<div className="dest-detail-hero" style={{ background: `linear-gradient(135deg,hsl(${dest.hue},60%,20%),hsl(${dest.hue + 40},50%,30%))` }}>
|
||||||
|
<span className="dest-detail-emoji">{dest.emoji}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dest-detail-body">
|
||||||
|
<div className="dest-detail-top">
|
||||||
|
<div>
|
||||||
|
<span className="modal-tag">{dest.tag}</span>
|
||||||
|
<h1>{dest.name}, {dest.country}</h1>
|
||||||
|
</div>
|
||||||
|
<FavoriteButton slug={dest.slug} />
|
||||||
|
</div>
|
||||||
|
<p className="dest-detail-desc">{dest.description}</p>
|
||||||
|
<div className="modal-stats">
|
||||||
|
<div className="modal-stat"><span className="stat-emoji">💰</span><strong>¥{dest.cost.toLocaleString()}</strong><span>月生活费</span></div>
|
||||||
|
<div className="modal-stat"><span className="stat-emoji">📶</span><strong>{dest.speed}Mbps</strong><span>网速</span></div>
|
||||||
|
<div className="modal-stat"><span className="stat-emoji">🌡️</span><strong>{dest.temperature}°C</strong><span>均温</span></div>
|
||||||
|
<div className="modal-stat"><span className="stat-emoji">⭐</span><strong>{dest.rating}</strong><span>评分</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-highlights">
|
||||||
|
<h4>✨ 亮点特色</h4>
|
||||||
|
<ul>{dest.highlights.map((h) => <li key={h}>{h}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
<DestinationDetailClient dest={dest} allDestinations={allDestinations} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
4479
frontend/src/app/globals.css
Normal file
4479
frontend/src/app/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/src/app/layout.tsx
Normal file
33
frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/lib/auth";
|
||||||
|
import { ToastProvider } from "@/lib/toast";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "NomadFlow · 数字游民旅居指南",
|
||||||
|
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
|
||||||
|
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
|
||||||
|
openGraph: {
|
||||||
|
title: "NomadFlow · 数字游民旅居指南",
|
||||||
|
description: "用一行代码环游世界 🌏",
|
||||||
|
type: "website",
|
||||||
|
locale: "zh_CN",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/app/loading.tsx
Normal file
8
frontend/src/app/loading.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="page-loading">
|
||||||
|
<div className="page-loading-spinner" />
|
||||||
|
<p>加载中... 🌍</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/app/login/page.tsx
Normal file
82
frontend/src/app/login/page.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import Link from "next/link";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login, register, demoLogin } = useAuth();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
const ok = mode === "login"
|
||||||
|
? await login(email, password)
|
||||||
|
: await register(email, password, name);
|
||||||
|
setLoading(false);
|
||||||
|
if (ok) window.location.href = "/";
|
||||||
|
else setError(mode === "login" ? "邮箱或密码错误" : "注册失败,邮箱可能已存在");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemo = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const ok = await demoLogin();
|
||||||
|
setLoading(false);
|
||||||
|
if (ok) window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell>
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-card">
|
||||||
|
<Link href="/" className="auth-back">← 返回首页</Link>
|
||||||
|
<div className="auth-header">
|
||||||
|
<span style={{ fontSize: "3rem" }}>🌍</span>
|
||||||
|
<h1>{mode === "login" ? "欢迎回来" : "加入 NomadFlow"}</h1>
|
||||||
|
<p>{mode === "login" ? "登录你的游民账户" : "开始你的旅居之旅"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-tabs">
|
||||||
|
<button className={mode === "login" ? "active" : ""} onClick={() => setMode("login")}>登录</button>
|
||||||
|
<button className={mode === "register" ? "active" : ""} onClick={() => setMode("register")}>注册</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{mode === "register" && (
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>👤 昵称</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="你的游民昵称" required />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>📧 邮箱</label>
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required />
|
||||||
|
</div>
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>🔒 密码</label>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="至少 6 位" required minLength={6} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ width: "100%" }} disabled={loading}>
|
||||||
|
{loading ? "处理中..." : mode === "login" ? "🚀 登录" : "✨ 注册"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-divider"><span>或</span></div>
|
||||||
|
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={handleDemo} disabled={loading}>
|
||||||
|
🎮 一键体验演示账号
|
||||||
|
</button>
|
||||||
|
<p className="auth-hint">演示账号:demo@nomadflow.io / demo123</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/app/not-found.tsx
Normal file
15
frontend/src/app/not-found.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<SiteShell>
|
||||||
|
<div className="not-found-page">
|
||||||
|
<span className="not-found-emoji">🧭</span>
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>哎呀,这片海域没有标记的岛屿</p>
|
||||||
|
<Link href="/" className="btn btn-primary">返回首页 🏠</Link>
|
||||||
|
</div>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/app/page.tsx
Normal file
60
frontend/src/app/page.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { api } from "@/lib/api";
|
||||||
|
import HomeClient from "@/components/HomeClient";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
try {
|
||||||
|
const [stats, ticker, destinations, visas, faqs, testimonials, tools, blog, cost, speed, growth, radar] =
|
||||||
|
await Promise.all([
|
||||||
|
api.getStats(),
|
||||||
|
api.getTicker(),
|
||||||
|
api.getDestinations(),
|
||||||
|
api.getVisas(),
|
||||||
|
api.getFaqs(),
|
||||||
|
api.getTestimonials(),
|
||||||
|
api.getTools(),
|
||||||
|
api.getBlog(),
|
||||||
|
api.getChartCost(),
|
||||||
|
api.getChartSpeed(),
|
||||||
|
api.getChartGrowth(),
|
||||||
|
api.getChartRadar(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeClient
|
||||||
|
stats={stats}
|
||||||
|
ticker={ticker.messages}
|
||||||
|
destinations={destinations}
|
||||||
|
visas={visas}
|
||||||
|
faqs={faqs}
|
||||||
|
testimonials={testimonials}
|
||||||
|
tools={tools}
|
||||||
|
blog={blog}
|
||||||
|
charts={{ cost, speed, growth, radar }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Minimal fallback if API completely unavailable
|
||||||
|
const stats = await api.getStats();
|
||||||
|
const ticker = await api.getTicker();
|
||||||
|
return (
|
||||||
|
<HomeClient
|
||||||
|
stats={stats}
|
||||||
|
ticker={ticker.messages}
|
||||||
|
destinations={[]}
|
||||||
|
visas={[]}
|
||||||
|
faqs={[]}
|
||||||
|
testimonials={[]}
|
||||||
|
tools={[]}
|
||||||
|
blog={[]}
|
||||||
|
charts={{
|
||||||
|
cost: { labels: [], datasets: [{ data: [] }] },
|
||||||
|
speed: { labels: [], datasets: [{ data: [] }] },
|
||||||
|
growth: { labels: [], datasets: [{ data: [] }] },
|
||||||
|
radar: { labels: [], datasets: [] },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
frontend/src/app/profile/page.tsx
Normal file
149
frontend/src/app/profile/page.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
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 SiteShell from "@/components/SiteShell";
|
||||||
|
import FavoriteButton from "@/components/FavoriteButton";
|
||||||
|
|
||||||
|
const TRIP_KEY = "nomadflow-trip";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { user, token, favorites, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [stats, setStats] = useState<ProfileStats | null>(null);
|
||||||
|
const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]);
|
||||||
|
const [trip, setTrip] = useState<TripItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
api.getProfileStats(token),
|
||||||
|
favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]),
|
||||||
|
]).then(([s, f]) => {
|
||||||
|
setStats(s);
|
||||||
|
setFavoriteDests(f);
|
||||||
|
}).catch(() => {}).finally(() => setLoading(false));
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(TRIP_KEY);
|
||||||
|
if (saved) setTrip(JSON.parse(saved));
|
||||||
|
}, [token, favorites, router]);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell>
|
||||||
|
<div className="profile-page">
|
||||||
|
<div className="profile-header">
|
||||||
|
<div className="profile-avatar">{user.avatar}</div>
|
||||||
|
<div className="profile-info">
|
||||||
|
<h1>{user.name}</h1>
|
||||||
|
<p>{user.email}</p>
|
||||||
|
{stats && <span className="profile-level">{stats.nomad_level}</span>}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="profile-stats-grid">
|
||||||
|
<div className="profile-stat-card">
|
||||||
|
<span className="stat-emoji">❤️</span>
|
||||||
|
<strong>{stats.favorites_count}</strong>
|
||||||
|
<span>收藏目的地</span>
|
||||||
|
</div>
|
||||||
|
<div className="profile-stat-card">
|
||||||
|
<span className="stat-emoji">🗺️</span>
|
||||||
|
<strong>{stats.destinations_explored}</strong>
|
||||||
|
<span>探索城市</span>
|
||||||
|
</div>
|
||||||
|
<div className="profile-stat-card">
|
||||||
|
<span className="stat-emoji">📅</span>
|
||||||
|
<strong>{stats.member_since}</strong>
|
||||||
|
<span>加入年份</span>
|
||||||
|
</div>
|
||||||
|
<div className="profile-stat-card">
|
||||||
|
<span className="stat-emoji">🧮</span>
|
||||||
|
<Link href="/#calculator" className="profile-link">费用计算器 →</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="profile-favorites">
|
||||||
|
<h2>❤️ 我的收藏</h2>
|
||||||
|
{loading ? (
|
||||||
|
<p className="profile-loading">加载中...</p>
|
||||||
|
) : favoriteDests.length === 0 ? (
|
||||||
|
<div className="profile-empty">
|
||||||
|
<span style={{ fontSize: "3rem" }}>🌍</span>
|
||||||
|
<p>还没有收藏目的地</p>
|
||||||
|
<Link href="/#destinations" className="btn btn-primary">去探索 →</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="profile-fav-grid">
|
||||||
|
{favoriteDests.map((d) => (
|
||||||
|
<div key={d.slug} className="profile-fav-card">
|
||||||
|
<Link href={`/destinations/${d.slug}`}>
|
||||||
|
<span className="fav-emoji">{d.emoji}</span>
|
||||||
|
<h3>{d.name}, {d.country}</h3>
|
||||||
|
<p>💰 ¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</p>
|
||||||
|
</Link>
|
||||||
|
<FavoriteButton slug={d.slug} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="profile-trip">
|
||||||
|
<h2>🗓️ 我的行程</h2>
|
||||||
|
{trip.length === 0 ? (
|
||||||
|
<div className="profile-empty">
|
||||||
|
<span style={{ fontSize: "2.5rem" }}>🗺️</span>
|
||||||
|
<p>还没有规划行程</p>
|
||||||
|
<Link href="/#trip" className="btn btn-primary">去规划 →</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="profile-trip-list">
|
||||||
|
{trip.map((t, i) => (
|
||||||
|
<div key={t.slug} className="profile-trip-item">
|
||||||
|
<span className="trip-item-num">{i + 1}</span>
|
||||||
|
<span>{t.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t.name}, {t.country}</strong>
|
||||||
|
<span>{t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="profile-trip-total">
|
||||||
|
总计 <strong>{trip.reduce((s, t) => s + t.months, 0)} 个月</strong> ·
|
||||||
|
<strong className="gradient-text"> ¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
<Link href="/#trip" className="btn btn-ghost">编辑行程 →</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="profile-quick">
|
||||||
|
<h2>🚀 快捷入口</h2>
|
||||||
|
<div className="profile-quick-grid">
|
||||||
|
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
|
||||||
|
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
|
||||||
|
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
|
||||||
|
<Link href="/#trip" className="quick-card">🗓️ 行程规划</Link>
|
||||||
|
<Link href="/#nomad-score" className="quick-card">📊 就绪度测评</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/app/robots.ts
Normal file
8
frontend/src/app/robots.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: { userAgent: "*", allow: "/" },
|
||||||
|
sitemap: "https://nomadflow.io/sitemap.xml",
|
||||||
|
};
|
||||||
|
}
|
||||||
27
frontend/src/app/sitemap.ts
Normal file
27
frontend/src/app/sitemap.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const DESTINATIONS = ["bali", "lisbon", "chiangmai", "mexico", "barcelona", "tokyo"];
|
||||||
|
const BLOGS = ["chiangmai-guide-2026", "portugal-d7-visa", "nomad-tax-basics"];
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const base = "https://nomadflow.io";
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ url: base, lastModified: now, changeFrequency: "weekly", priority: 1 },
|
||||||
|
{ url: `${base}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||||
|
{ url: `${base}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||||
|
...DESTINATIONS.map((slug) => ({
|
||||||
|
url: `${base}/destinations/${slug}`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: "weekly" as const,
|
||||||
|
priority: 0.8,
|
||||||
|
})),
|
||||||
|
...BLOGS.map((slug) => ({
|
||||||
|
url: `${base}/blog/${slug}`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: "monthly" as const,
|
||||||
|
priority: 0.7,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
21
frontend/src/components/BackToTop.tsx
Normal file
21
frontend/src/components/BackToTop.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function BackToTop() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setVisible(window.scrollY > 600);
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={`back-to-top${visible ? " visible" : ""}`}
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
aria-label="返回顶部">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/BlogReadingProgress.tsx
Normal file
31
frontend/src/components/BlogReadingProgress.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function BlogReadingProgress() {
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
const article = document.querySelector(".blog-detail-content");
|
||||||
|
if (!article) return;
|
||||||
|
const rect = article.getBoundingClientRect();
|
||||||
|
const articleTop = rect.top + window.scrollY;
|
||||||
|
const articleHeight = article.scrollHeight;
|
||||||
|
const scrolled = window.scrollY - articleTop + window.innerHeight * 0.3;
|
||||||
|
setProgress(Math.min(Math.max((scrolled / articleHeight) * 100, 0), 100));
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="blog-reading-progress" aria-hidden="true">
|
||||||
|
<div className="blog-reading-bar" style={{ width: `${progress}%` }} />
|
||||||
|
{progress > 5 && progress < 98 && (
|
||||||
|
<span className="blog-reading-pct">{Math.round(progress)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/BlogSection.tsx
Normal file
33
frontend/src/components/BlogSection.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { BlogPost } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function BlogSection({ posts }: { posts: BlogPost[] }) {
|
||||||
|
return (
|
||||||
|
<section className="section blog-section" id="blog">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">📝 BLOG</span>
|
||||||
|
<h2>游民博客</h2>
|
||||||
|
<p>深度攻略、签证指南与旅居经验分享</p>
|
||||||
|
</div>
|
||||||
|
<div className="blog-grid">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Link key={post.slug} href={`/blog/${post.slug}`} className="blog-card reveal">
|
||||||
|
<div className="blog-emoji">{post.emoji}</div>
|
||||||
|
<div className="blog-meta">
|
||||||
|
<span>📅 {post.published_at}</span>
|
||||||
|
<span>⏱️ {post.read_time} 分钟阅读</span>
|
||||||
|
</div>
|
||||||
|
<h3>{post.title}</h3>
|
||||||
|
<p>{post.excerpt}</p>
|
||||||
|
<div className="blog-tags">
|
||||||
|
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
<span className="blog-read-more">阅读全文 →</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/src/components/Calculator.tsx
Normal file
100
frontend/src/components/Calculator.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { CostResult, Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props { destinations: Destination[] }
|
||||||
|
|
||||||
|
export default function Calculator({ destinations }: Props) {
|
||||||
|
const [slug, setSlug] = useState(destinations[0]?.slug || "");
|
||||||
|
const [housing, setHousing] = useState("mid");
|
||||||
|
const [months, setMonths] = useState(3);
|
||||||
|
const [result, setResult] = useState<CostResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const calculate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.calculateCost({ destination_slug: slug, housing, months });
|
||||||
|
setResult(data);
|
||||||
|
} catch {
|
||||||
|
setResult(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section calculator-section" id="calculator">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🧮 CALCULATOR</span>
|
||||||
|
<h2>旅居费用计算器</h2>
|
||||||
|
<p>根据目的地和生活方式,精准估算你的旅居预算</p>
|
||||||
|
</div>
|
||||||
|
<div className="calculator-wrapper reveal">
|
||||||
|
<div className="calculator-form">
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>🏙️ 目的地</label>
|
||||||
|
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
|
||||||
|
{destinations.map((d) => (
|
||||||
|
<option key={d.slug} value={d.slug}>{d.emoji} {d.name}, {d.country}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>🏠 住宿档次</label>
|
||||||
|
<div className="calc-options">
|
||||||
|
{[{ v: "budget", l: "💰 经济" }, { v: "mid", l: "🏠 舒适" }, { v: "premium", l: "✨ 豪华" }].map((o) => (
|
||||||
|
<button key={o.v} className={`calc-option${housing === o.v ? " active" : ""}`}
|
||||||
|
onClick={() => setHousing(o.v)}>{o.l}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>📅 旅居月数: {months} 个月</label>
|
||||||
|
<input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={calculate} disabled={loading}>
|
||||||
|
{loading ? "计算中..." : "🧮 开始计算"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="calculator-result">
|
||||||
|
{result ? (
|
||||||
|
<div className="result-card">
|
||||||
|
<div className="result-header">
|
||||||
|
<span style={{ fontSize: "3rem" }}>{result.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<h3>{result.destination}</h3>
|
||||||
|
<p>{result.months} 个月旅居预算</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="result-breakdown">
|
||||||
|
{Object.entries(result.breakdown).map(([k, v]) => (
|
||||||
|
<div key={k} className="breakdown-item">
|
||||||
|
<span>{k}</span>
|
||||||
|
<div className="breakdown-bar">
|
||||||
|
<div className="breakdown-fill" style={{ width: `${(v / result.per_month) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<strong>¥{v.toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="result-total">
|
||||||
|
<div><span>月均</span><strong>¥{result.per_month.toLocaleString()}</strong></div>
|
||||||
|
<div><span>总计</span><strong className="gradient-text">¥{result.total.toLocaleString()}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="result-placeholder">
|
||||||
|
<span style={{ fontSize: "4rem" }}>🧮</span>
|
||||||
|
<p>选择目的地和参数,点击计算查看预算明细</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
frontend/src/components/Charts.tsx
Normal file
109
frontend/src/components/Charts.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { ChartData } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cost: ChartData;
|
||||||
|
speed: ChartData;
|
||||||
|
growth: ChartData;
|
||||||
|
radar: ChartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#FF6B6B", "#FFE66D", "#4ECDC4", "#A78BFA", "#F472B6", "#60A5FA"];
|
||||||
|
|
||||||
|
function loadChartJS(): Promise<typeof import("chart.js")> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof window !== "undefined" && (window as unknown as { Chart?: unknown }).Chart) {
|
||||||
|
resolve({ Chart: (window as unknown as { Chart: typeof import("chart.js").Chart }).Chart } as typeof import("chart.js"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js";
|
||||||
|
script.onload = () => {
|
||||||
|
const Chart = (window as unknown as { Chart: typeof import("chart.js").Chart }).Chart;
|
||||||
|
resolve({ Chart } as typeof import("chart.js"));
|
||||||
|
};
|
||||||
|
script.onerror = reject;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Charts({ cost, speed, growth, radar }: Props) {
|
||||||
|
const costRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const speedRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const growthRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const radarRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let charts: InstanceType<typeof import("chart.js").Chart>[] = [];
|
||||||
|
|
||||||
|
loadChartJS().then(({ Chart }) => {
|
||||||
|
const isLight = document.documentElement.getAttribute("data-theme") === "light";
|
||||||
|
const text = isLight ? "#475569" : "#94a3b8";
|
||||||
|
const grid = isLight ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.06)";
|
||||||
|
|
||||||
|
if (costRef.current && cost.datasets[0]?.data.length) {
|
||||||
|
charts.push(new Chart(costRef.current, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: cost.labels,
|
||||||
|
datasets: [{ label: "月生活费 (元)", data: cost.datasets[0].data,
|
||||||
|
backgroundColor: COLORS.map((c) => c + "99"), borderColor: COLORS, borderWidth: 2, borderRadius: 8 }],
|
||||||
|
},
|
||||||
|
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } },
|
||||||
|
scales: { y: { beginAtZero: true, grid: { color: grid }, ticks: { color: text } }, x: { grid: { display: false }, ticks: { color: text } } } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (speedRef.current && speed.datasets[0]?.data.length) {
|
||||||
|
charts.push(new Chart(speedRef.current, {
|
||||||
|
type: "doughnut",
|
||||||
|
data: { labels: speed.labels, datasets: [{ data: speed.datasets[0].data, backgroundColor: COLORS }] },
|
||||||
|
options: { responsive: true, maintainAspectRatio: false, cutout: "65%", plugins: { legend: { position: "right", labels: { color: text } } } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (growthRef.current && growth.datasets[0]?.data.length) {
|
||||||
|
charts.push(new Chart(growthRef.current, {
|
||||||
|
type: "line",
|
||||||
|
data: { labels: growth.labels, datasets: [{ ...growth.datasets[0], borderColor: COLORS[2], backgroundColor: COLORS[2] + "20", fill: true, tension: 0.4 }] },
|
||||||
|
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } },
|
||||||
|
scales: { y: { beginAtZero: true, grid: { color: grid }, ticks: { color: text } }, x: { grid: { display: false }, ticks: { color: text } } } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (radarRef.current && radar.datasets.length) {
|
||||||
|
charts.push(new Chart(radarRef.current, {
|
||||||
|
type: "radar",
|
||||||
|
data: {
|
||||||
|
labels: radar.labels,
|
||||||
|
datasets: radar.datasets.map((ds, i) => ({
|
||||||
|
...ds, borderColor: COLORS[i], backgroundColor: COLORS[i] + "30", pointBackgroundColor: COLORS[i],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
options: { responsive: true, maintainAspectRatio: false,
|
||||||
|
scales: { r: { beginAtZero: true, max: 100, grid: { color: grid }, angleLines: { color: grid }, pointLabels: { color: text }, ticks: { display: false } } },
|
||||||
|
plugins: { legend: { position: "bottom", labels: { color: text } } } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => charts.forEach((c) => c.destroy());
|
||||||
|
}, [cost, speed, growth, radar]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section data-section" id="data">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">📊 DATA INSIGHTS</span>
|
||||||
|
<h2>旅居数据洞察</h2>
|
||||||
|
<p>用数据帮你做出更明智的目的地选择</p>
|
||||||
|
</div>
|
||||||
|
<div className="charts-grid">
|
||||||
|
<div className="chart-card reveal"><h3>💰 月度生活成本对比</h3><div className="chart-wrap"><canvas ref={costRef} /></div></div>
|
||||||
|
<div className="chart-card reveal"><h3>📶 网络速度排行</h3><div className="chart-wrap"><canvas ref={speedRef} /></div></div>
|
||||||
|
<div className="chart-card chart-wide reveal"><h3>📈 数字游民增长趋势 (2019-2026)</h3><div className="chart-wrap"><canvas ref={growthRef} /></div></div>
|
||||||
|
<div className="chart-card reveal"><h3>🎯 选择因素权重</h3><div className="chart-wrap chart-radar"><canvas ref={radarRef} /></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/components/Community.tsx
Normal file
59
frontend/src/components/Community.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Testimonial } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function Community({ testimonials }: { testimonials: Testimonial[] }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleSubscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
|
||||||
|
try {
|
||||||
|
await api.subscribe(email);
|
||||||
|
toast("订阅成功!欢迎加入 NomadFlow 社区 🎉");
|
||||||
|
form.reset();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="section community" id="community">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🤝 COMMUNITY</span>
|
||||||
|
<h2>游民心声</h2>
|
||||||
|
<p>来自全球数字游民的真实故事</p>
|
||||||
|
</div>
|
||||||
|
<div className="testimonials">
|
||||||
|
{testimonials.map((t) => (
|
||||||
|
<div key={t.id} className="testimonial-card reveal">
|
||||||
|
<div className="testimonial-avatar">{t.avatar}</div>
|
||||||
|
<div className="testimonial-content">
|
||||||
|
<div className="testimonial-stars">{"⭐".repeat(t.rating)}</div>
|
||||||
|
<p>“{t.content}”</p>
|
||||||
|
<div className="testimonial-author">
|
||||||
|
<strong>{t.author}</strong>
|
||||||
|
<span>{t.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="cta-banner reveal">
|
||||||
|
<div className="cta-content">
|
||||||
|
<h2>准备好开始你的旅居之旅了吗? 🌍</h2>
|
||||||
|
<p>加入 50,000+ 数字游民社区,获取目的地指南、签证攻略与独家优惠</p>
|
||||||
|
</div>
|
||||||
|
<form className="cta-form" onSubmit={handleSubscribe}>
|
||||||
|
<input type="email" name="email" placeholder="输入你的邮箱 📧" required />
|
||||||
|
<button type="submit" className="btn btn-primary"><span>免费订阅</span><span>🚀</span></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/src/components/CompareModal.tsx
Normal file
127
frontend/src/components/CompareModal.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
destinations: Destination[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Metric {
|
||||||
|
label: string;
|
||||||
|
emoji: string;
|
||||||
|
get: (d: Destination) => number;
|
||||||
|
format: (v: number) => string;
|
||||||
|
lowerBetter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRICS: Metric[] = [
|
||||||
|
{ label: "月生活费", emoji: "💰", get: (d) => d.cost, format: (v) => `¥${v.toLocaleString()}`, lowerBetter: true },
|
||||||
|
{ label: "网速", emoji: "📶", get: (d) => d.speed, format: (v) => `${v} Mbps` },
|
||||||
|
{ label: "温度", emoji: "🌡️", get: (d) => d.temperature, format: (v) => `${v}°C` },
|
||||||
|
{ label: "评分", emoji: "⭐", get: (d) => d.rating, format: (v) => v.toFixed(1) },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getWinnerIdx(values: number[], lowerBetter?: boolean): number {
|
||||||
|
if (values.length === 0) return -1;
|
||||||
|
const best = lowerBetter ? Math.min(...values) : Math.max(...values);
|
||||||
|
return values.indexOf(best);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overallScore(d: Destination): number {
|
||||||
|
const costScore = Math.max(0, 100 - d.cost / 150);
|
||||||
|
const speedScore = d.speed / 2;
|
||||||
|
const ratingScore = d.rating * 10;
|
||||||
|
return Math.round(costScore * 0.35 + speedScore * 0.25 + ratingScore * 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompareModal({ destinations, onClose }: Props) {
|
||||||
|
const scores = destinations.map(overallScore);
|
||||||
|
const winnerIdx = scores.indexOf(Math.max(...scores));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal compare-modal">
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="关闭">✕</button>
|
||||||
|
<div className="modal-body compare-modal-body">
|
||||||
|
<div className="compare-modal-header">
|
||||||
|
<span className="section-tag">⚖️ COMPARE</span>
|
||||||
|
<h2>城市对比分析</h2>
|
||||||
|
<p>可视化对比关键指标,帮你做出最佳选择</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-cards">
|
||||||
|
{destinations.map((d, i) => (
|
||||||
|
<div key={d.slug} className={`compare-city-card${i === winnerIdx ? " winner" : ""}`}>
|
||||||
|
{i === winnerIdx && <span className="compare-winner-badge">👑 综合推荐</span>}
|
||||||
|
<span className="compare-city-emoji">{d.emoji}</span>
|
||||||
|
<h3>{d.name}</h3>
|
||||||
|
<span className="compare-city-country">{d.country}</span>
|
||||||
|
<div className="compare-score-ring">
|
||||||
|
<svg viewBox="0 0 80 80">
|
||||||
|
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="6" />
|
||||||
|
<circle
|
||||||
|
cx="40" cy="40" r="34" fill="none"
|
||||||
|
stroke="url(#scoreGrad)" strokeWidth="6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${scores[i] * 2.14} 214`}
|
||||||
|
transform="rotate(-90 40 40)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="compare-score-num">{scores[i]}</span>
|
||||||
|
</div>
|
||||||
|
<Link href={`/destinations/${d.slug}`} className="compare-city-link">查看详情 →</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-metrics">
|
||||||
|
{METRICS.map((m) => {
|
||||||
|
const values = destinations.map(m.get);
|
||||||
|
const winIdx = getWinnerIdx(values, m.lowerBetter);
|
||||||
|
const maxVal = Math.max(...values);
|
||||||
|
return (
|
||||||
|
<div key={m.label} className="compare-metric-row">
|
||||||
|
<div className="compare-metric-label">{m.emoji} {m.label}</div>
|
||||||
|
<div className="compare-metric-bars">
|
||||||
|
{destinations.map((d, i) => {
|
||||||
|
const val = m.get(d);
|
||||||
|
const pct = maxVal > 0 ? (val / maxVal) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={d.slug} className="compare-bar-item">
|
||||||
|
<div className="compare-bar-header">
|
||||||
|
<span>{d.emoji} {d.name}</span>
|
||||||
|
<span className={i === winIdx ? "compare-best" : ""}>
|
||||||
|
{i === winIdx && "🏆 "}{m.format(val)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="compare-bar-track">
|
||||||
|
<div
|
||||||
|
className={`compare-bar-fill${i === winIdx ? " best" : ""}`}
|
||||||
|
style={{ width: `${pct}%`, animationDelay: `${i * 0.1}s` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="0" height="0">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="scoreGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#FF6B6B" />
|
||||||
|
<stop offset="50%" stopColor="#FFE66D" />
|
||||||
|
<stop offset="100%" stopColor="#4ECDC4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
frontend/src/components/CurrencyConverter.tsx
Normal file
136
frontend/src/components/CurrencyConverter.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const CURRENCIES = [
|
||||||
|
{ code: "CNY", name: "人民币", flag: "🇨🇳", rate: 1 },
|
||||||
|
{ code: "USD", name: "美元", flag: "🇺🇸", rate: 0.138 },
|
||||||
|
{ code: "EUR", name: "欧元", flag: "🇪🇺", rate: 0.127 },
|
||||||
|
{ code: "THB", name: "泰铢", flag: "🇹🇭", rate: 4.85 },
|
||||||
|
{ code: "IDR", name: "印尼盾", flag: "🇮🇩", rate: 2180 },
|
||||||
|
{ code: "JPY", name: "日元", flag: "🇯🇵", rate: 20.5 },
|
||||||
|
{ code: "MXN", name: "墨西哥比索", flag: "🇲🇽", rate: 2.35 },
|
||||||
|
{ code: "GBP", name: "英镑", flag: "🇬🇧", rate: 0.109 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: "清迈月租", amount: 2500, from: "CNY", emoji: "🏔️" },
|
||||||
|
{ label: "巴厘岛生活费", amount: 4500, from: "CNY", emoji: "🏝️" },
|
||||||
|
{ label: "里斯本月租", amount: 800, from: "EUR", emoji: "🌊" },
|
||||||
|
{ label: "东京月租", amount: 120000, from: "JPY", emoji: "🗼" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CurrencyConverter() {
|
||||||
|
const [amount, setAmount] = useState(5000);
|
||||||
|
const [from, setFrom] = useState("CNY");
|
||||||
|
const [to, setTo] = useState("USD");
|
||||||
|
const [swapping, setSwapping] = useState(false);
|
||||||
|
|
||||||
|
const result = useMemo(() => {
|
||||||
|
const fromRate = CURRENCIES.find((c) => c.code === from)?.rate ?? 1;
|
||||||
|
const toRate = CURRENCIES.find((c) => c.code === to)?.rate ?? 1;
|
||||||
|
return (amount / fromRate) * toRate;
|
||||||
|
}, [amount, from, to]);
|
||||||
|
|
||||||
|
const swap = () => {
|
||||||
|
setSwapping(true);
|
||||||
|
setFrom(to);
|
||||||
|
setTo(from);
|
||||||
|
setTimeout(() => setSwapping(false), 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (p: typeof PRESETS[0]) => {
|
||||||
|
setAmount(p.amount);
|
||||||
|
setFrom(p.from);
|
||||||
|
setTo("CNY");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromCur = CURRENCIES.find((c) => c.code === from)!;
|
||||||
|
const toCur = CURRENCIES.find((c) => c.code === to)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section currency-section" id="currency">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">💱 CURRENCY</span>
|
||||||
|
<h2>多币种换算器</h2>
|
||||||
|
<p>快速换算旅居生活费,规划跨国预算更轻松</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="currency-wrapper reveal">
|
||||||
|
<div className="currency-card">
|
||||||
|
<div className="currency-input-group">
|
||||||
|
<label>💰 金额</label>
|
||||||
|
<div className="currency-input-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(Math.max(0, +e.target.value || 0))}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<select value={from} onChange={(e) => setFrom(e.target.value)}>
|
||||||
|
{CURRENCIES.map((c) => (
|
||||||
|
<option key={c.code} value={c.code}>{c.flag} {c.code}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={`currency-swap${swapping ? " swapping" : ""}`} onClick={swap} aria-label="交换货币">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M7 16V4M7 4L3 8M7 4l4 4M17 8v12M17 20l4-4M17 20l-4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="currency-result">
|
||||||
|
<label>✨ 换算结果</label>
|
||||||
|
<div className="currency-result-display">
|
||||||
|
<span className="currency-result-flag">{toCur.flag}</span>
|
||||||
|
<span className="currency-result-amount">
|
||||||
|
{result.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
<span className="currency-result-code">{toCur.code}</span>
|
||||||
|
</div>
|
||||||
|
<p className="currency-rate-hint">
|
||||||
|
1 {fromCur.code} ≈ {((fromCur.rate / toCur.rate)).toLocaleString(undefined, { maximumFractionDigits: 4 })} {toCur.code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="currency-to-select">
|
||||||
|
<label>兑换为</label>
|
||||||
|
<div className="currency-chips">
|
||||||
|
{CURRENCIES.filter((c) => c.code !== from).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.code}
|
||||||
|
className={`currency-chip${to === c.code ? " active" : ""}`}
|
||||||
|
onClick={() => setTo(c.code)}
|
||||||
|
>
|
||||||
|
{c.flag} {c.code}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="currency-presets">
|
||||||
|
<h3>🏙️ 快捷参考</h3>
|
||||||
|
<p className="currency-presets-desc">一键填入典型旅居开销</p>
|
||||||
|
<div className="currency-preset-grid">
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button key={p.label} className="currency-preset-card" onClick={() => applyPreset(p)}>
|
||||||
|
<span className="preset-emoji">{p.emoji}</span>
|
||||||
|
<strong>{p.label}</strong>
|
||||||
|
<span>{p.amount.toLocaleString()} {p.from}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="currency-tip">
|
||||||
|
<span>💡</span>
|
||||||
|
<p>实际汇率会波动,建议用 Wise 或 Revolut 获取实时汇率。此处为参考汇率。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
frontend/src/components/DestinationDetailClient.tsx
Normal file
140
frontend/src/components/DestinationDetailClient.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "nomadflow-trip";
|
||||||
|
const TZ_LABELS: Record<string, string> = {
|
||||||
|
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
|
||||||
|
mexico: "UTC-6", barcelona: "UTC+1", tokyo: "UTC+9",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dest: Destination;
|
||||||
|
allDestinations: Destination[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScores(d: Destination) {
|
||||||
|
const nomads = parseInt(d.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
|
||||||
|
return [
|
||||||
|
{ label: "性价比", value: Math.round((12000 - d.cost) / 120), emoji: "💰" },
|
||||||
|
{ label: "网速", value: Math.round(d.speed / 2), emoji: "📶" },
|
||||||
|
{ label: "气候", value: Math.round(100 - Math.abs(d.temperature - 24) * 4), emoji: "🌡️" },
|
||||||
|
{ label: "评分", value: Math.round(d.rating * 10), emoji: "⭐" },
|
||||||
|
{ label: "社区", value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [added, setAdded] = useState(false);
|
||||||
|
const scores = getScores(dest);
|
||||||
|
|
||||||
|
const related = allDestinations
|
||||||
|
.filter((d) => d.region === dest.region && d.slug !== dest.slug)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
const addToTrip = () => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const trip = saved ? JSON.parse(saved) : [];
|
||||||
|
if (trip.some((t: { slug: string }) => t.slug === dest.slug)) {
|
||||||
|
toast("该城市已在行程中", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1 });
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(trip));
|
||||||
|
setAdded(true);
|
||||||
|
toast(`${dest.emoji} ${dest.name} 已加入行程 🗓️`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const share = async () => {
|
||||||
|
const url = window.location.href;
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({ title: `${dest.name} - NomadFlow`, url });
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast("链接已复制到剪贴板 📋");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const trip = JSON.parse(saved);
|
||||||
|
setAdded(trip.some((t: { slug: string }) => t.slug === dest.slug));
|
||||||
|
}
|
||||||
|
}, [dest.slug]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dest-detail-actions">
|
||||||
|
<button className={`btn ${added ? "btn-ghost" : "btn-primary"}`} onClick={addToTrip} disabled={added}>
|
||||||
|
{added ? "✓ 已在行程中" : "🗓️ 加入行程"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
||||||
|
<Link href={`/#calculator`} className="btn btn-ghost">🧮 算费用</Link>
|
||||||
|
<Link href={`/#timezone`} className="btn btn-ghost">🕐 看时区</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dest-detail-extras">
|
||||||
|
<div className="dest-radar-card">
|
||||||
|
<h3>📊 城市画像</h3>
|
||||||
|
<div className="dest-radar-bars">
|
||||||
|
{scores.map((s) => (
|
||||||
|
<div key={s.label} className="dest-radar-row">
|
||||||
|
<span className="dest-radar-label">{s.emoji} {s.label}</span>
|
||||||
|
<div className="dest-radar-track">
|
||||||
|
<div className="dest-radar-fill" style={{ width: `${Math.min(s.value, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="dest-radar-val">{Math.min(s.value, 100)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dest-info-cards">
|
||||||
|
<div className="dest-info-mini">
|
||||||
|
<span>🕐</span>
|
||||||
|
<div>
|
||||||
|
<strong>时区</strong>
|
||||||
|
<span>{TZ_LABELS[dest.slug] || "查看时区看板"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dest-info-mini">
|
||||||
|
<span>👥</span>
|
||||||
|
<div>
|
||||||
|
<strong>游民社区</strong>
|
||||||
|
<span>{dest.nomads_count} 活跃</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dest-info-mini">
|
||||||
|
<span>🌏</span>
|
||||||
|
<div>
|
||||||
|
<strong>区域</strong>
|
||||||
|
<span>{dest.tag.split("·")[0]?.trim()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{related.length > 0 && (
|
||||||
|
<div className="dest-related">
|
||||||
|
<h3>🔗 同区域推荐</h3>
|
||||||
|
<div className="dest-related-grid">
|
||||||
|
{related.map((d) => (
|
||||||
|
<Link key={d.slug} href={`/destinations/${d.slug}`} className="dest-related-card">
|
||||||
|
<span>{d.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{d.name}</strong>
|
||||||
|
<span>¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
frontend/src/components/DestinationMatcher.tsx
Normal file
217
frontend/src/components/DestinationMatcher.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
destinations: Destination[];
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Budget = "low" | "medium" | "high";
|
||||||
|
type Climate = "warm" | "mild" | "cool";
|
||||||
|
type Priority = "cost" | "speed" | "community" | "lifestyle";
|
||||||
|
type Region = "any" | "sea" | "europe" | "latam" | "asia";
|
||||||
|
|
||||||
|
interface Prefs {
|
||||||
|
budget: Budget | null;
|
||||||
|
climate: Climate | null;
|
||||||
|
priority: Priority | null;
|
||||||
|
region: Region | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ title: "你的月预算?", subtitle: "包含住宿、餐饮、交通等日常开销", key: "budget" as const },
|
||||||
|
{ title: "偏好什么气候?", subtitle: "选择最让你舒适的环境", key: "climate" as const },
|
||||||
|
{ title: "最看重什么?", subtitle: "我们会据此为你智能排序", key: "priority" as const },
|
||||||
|
{ title: "想去哪个区域?", subtitle: "可以选「不限」探索全球", key: "region" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTIONS = {
|
||||||
|
budget: [
|
||||||
|
{ value: "low" as Budget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" },
|
||||||
|
{ value: "medium" as Budget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" },
|
||||||
|
{ value: "high" as Budget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" },
|
||||||
|
],
|
||||||
|
climate: [
|
||||||
|
{ value: "warm" as Climate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" },
|
||||||
|
{ value: "mild" as Climate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" },
|
||||||
|
{ value: "cool" as Climate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" },
|
||||||
|
],
|
||||||
|
priority: [
|
||||||
|
{ value: "cost" as Priority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" },
|
||||||
|
{ value: "speed" as Priority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" },
|
||||||
|
{ value: "community" as Priority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" },
|
||||||
|
{ value: "lifestyle" as Priority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" },
|
||||||
|
],
|
||||||
|
region: [
|
||||||
|
{ value: "any" as Region, emoji: "🌏", label: "不限", desc: "全球探索" },
|
||||||
|
{ value: "sea" as Region, emoji: "🌴", label: "东南亚", desc: "性价比之王" },
|
||||||
|
{ value: "europe" as Region, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" },
|
||||||
|
{ value: "latam" as Region, emoji: "🌮", label: "拉美", desc: "活力与北美时区" },
|
||||||
|
{ value: "asia" as Region, emoji: "🏯", label: "东亚", desc: "安全高效现代" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseNomads(n: string): number {
|
||||||
|
return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreDestination(d: Destination, prefs: Required<Pick<Prefs, "budget" | "climate" | "priority" | "region">>): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (prefs.budget === "low") score += d.cost <= 5000 ? 35 : Math.max(0, 35 - (d.cost - 5000) / 200);
|
||||||
|
else if (prefs.budget === "medium") score += d.cost >= 4500 && d.cost <= 9500 ? 35 : 35 - Math.abs(d.cost - 7000) / 300;
|
||||||
|
else score += d.cost >= 9000 ? 35 : Math.max(0, 35 - (9000 - d.cost) / 200);
|
||||||
|
|
||||||
|
if (prefs.climate === "warm") score += d.temperature >= 25 ? 25 : Math.max(0, 25 - (25 - d.temperature) * 3);
|
||||||
|
else if (prefs.climate === "mild") score += d.temperature >= 18 && d.temperature <= 25 ? 25 : 25 - Math.abs(d.temperature - 21) * 2;
|
||||||
|
else score += d.temperature <= 18 ? 25 : Math.max(0, 25 - (d.temperature - 18) * 3);
|
||||||
|
|
||||||
|
if (prefs.priority === "cost") score += (12000 - d.cost) / 80;
|
||||||
|
else if (prefs.priority === "speed") score += d.speed / 4;
|
||||||
|
else if (prefs.priority === "community") score += parseNomads(d.nomads_count) / 500;
|
||||||
|
else score += d.rating * 3;
|
||||||
|
|
||||||
|
if (prefs.region === "any" || d.region === prefs.region) score += 20;
|
||||||
|
else score -= 10;
|
||||||
|
|
||||||
|
score += d.rating * 2;
|
||||||
|
return Math.round(score);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DestinationMatcher({ destinations, open, onClose }: Props) {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [prefs, setPrefs] = useState<Prefs>({ budget: null, climate: null, priority: null, region: null });
|
||||||
|
const [direction, setDirection] = useState<"forward" | "back">("forward");
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!prefs.budget || !prefs.climate || !prefs.priority || !prefs.region) return [];
|
||||||
|
return [...destinations]
|
||||||
|
.map((d) => ({ dest: d, score: scoreDestination(d, prefs as Required<typeof prefs>) }))
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}, [destinations, prefs]);
|
||||||
|
|
||||||
|
const currentKey = STEPS[step]?.key;
|
||||||
|
const isComplete = step >= STEPS.length;
|
||||||
|
const progress = isComplete ? 100 : ((step + 1) / STEPS.length) * 100;
|
||||||
|
|
||||||
|
const select = (value: string) => {
|
||||||
|
if (!currentKey) return;
|
||||||
|
setDirection("forward");
|
||||||
|
setPrefs((p) => ({ ...p, [currentKey]: value }));
|
||||||
|
setTimeout(() => setStep((s) => s + 1), 280);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (step === 0) { onClose(); return; }
|
||||||
|
setDirection("back");
|
||||||
|
setStep((s) => s - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep(0);
|
||||||
|
setPrefs({ budget: null, climate: null, priority: null, region: null });
|
||||||
|
setDirection("forward");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay open matcher-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal matcher-modal">
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="关闭">✕</button>
|
||||||
|
|
||||||
|
<div className="matcher-progress">
|
||||||
|
<div className="matcher-progress-bar" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="matcher-body">
|
||||||
|
{!isComplete ? (
|
||||||
|
<div className={`matcher-step matcher-${direction}`} key={step}>
|
||||||
|
<div className="matcher-step-meta">
|
||||||
|
<span className="section-tag">🎯 MATCHER · {step + 1}/{STEPS.length}</span>
|
||||||
|
<h2>{STEPS[step].title}</h2>
|
||||||
|
<p>{STEPS[step].subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="matcher-options">
|
||||||
|
{OPTIONS[currentKey].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
className="matcher-option"
|
||||||
|
onClick={() => select(opt.value)}
|
||||||
|
>
|
||||||
|
<span className="matcher-option-emoji">{opt.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{opt.label}</strong>
|
||||||
|
<span>{opt.desc}</span>
|
||||||
|
</div>
|
||||||
|
<svg className="matcher-option-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="matcher-back" onClick={goBack}>
|
||||||
|
{step === 0 ? "取消" : "← 上一步"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="matcher-results matcher-forward">
|
||||||
|
<div className="matcher-step-meta">
|
||||||
|
<span className="section-tag">✨ YOUR MATCHES</span>
|
||||||
|
<h2>为你推荐的目的地</h2>
|
||||||
|
<p>根据你的偏好智能匹配,匹配度越高越适合你</p>
|
||||||
|
</div>
|
||||||
|
<div className="matcher-result-list">
|
||||||
|
{results.map(({ dest, score }, i) => (
|
||||||
|
<Link
|
||||||
|
key={dest.slug}
|
||||||
|
href={`/destinations/${dest.slug}`}
|
||||||
|
className={`matcher-result-card${i === 0 ? " top" : ""}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className="matcher-result-rank">
|
||||||
|
{i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`}
|
||||||
|
</div>
|
||||||
|
<span className="matcher-result-emoji">{dest.emoji}</span>
|
||||||
|
<div className="matcher-result-info">
|
||||||
|
<strong>{dest.name}</strong>
|
||||||
|
<span>{dest.country} · {dest.tag}</span>
|
||||||
|
</div>
|
||||||
|
<div className="matcher-result-score">
|
||||||
|
<div className="matcher-score-bar">
|
||||||
|
<div className="matcher-score-fill" style={{ width: `${Math.min((score / (results[0]?.score || 1)) * 100, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span>{Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100)}% 匹配</span>
|
||||||
|
</div>
|
||||||
|
<div className="matcher-result-tags">
|
||||||
|
<span>💰 ¥{dest.cost.toLocaleString()}</span>
|
||||||
|
<span>📶 {dest.speed}Mbps</span>
|
||||||
|
<span>⭐ {dest.rating}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="matcher-actions">
|
||||||
|
<button className="btn btn-ghost" onClick={reset}>🔄 重新测试</button>
|
||||||
|
<button className="btn btn-primary" onClick={onClose}>开始探索 🚀</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatcherTrigger({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className="matcher-fab" onClick={onClick} aria-label="智能匹配">
|
||||||
|
<span className="matcher-fab-icon">🎯</span>
|
||||||
|
<span className="matcher-fab-text">智能匹配</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend/src/components/Destinations.tsx
Normal file
122
frontend/src/components/Destinations.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
import FavoriteButton from "./FavoriteButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
destinations: Destination[];
|
||||||
|
onCompare?: (slug: string) => void;
|
||||||
|
compareList?: string[];
|
||||||
|
onOpenMatcher?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS = [
|
||||||
|
{ key: "all", label: "🌏 全部" },
|
||||||
|
{ key: "sea", label: "🌴 东南亚" },
|
||||||
|
{ key: "europe", label: "🏰 欧洲" },
|
||||||
|
{ key: "latam", label: "🌮 拉美" },
|
||||||
|
{ key: "asia", label: "🏯 东亚" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Destinations({ destinations, onCompare, compareList = [], onOpenMatcher }: Props) {
|
||||||
|
const [filter, setFilter] = useState("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sort, setSort] = useState("rating");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = [...destinations];
|
||||||
|
if (filter !== "all") list = list.filter((d) => d.region === filter);
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
list = list.filter((d) => d.name.toLowerCase().includes(q) || d.country.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
if (sort === "cost-asc") list.sort((a, b) => a.cost - b.cost);
|
||||||
|
else if (sort === "cost-desc") list.sort((a, b) => b.cost - a.cost);
|
||||||
|
else if (sort === "speed") list.sort((a, b) => b.speed - a.speed);
|
||||||
|
else list.sort((a, b) => b.rating - a.rating);
|
||||||
|
return list;
|
||||||
|
}, [destinations, filter, search, sort]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section destinations" id="destinations">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🌴 TOP DESTINATIONS</span>
|
||||||
|
<h2>热门旅居目的地</h2>
|
||||||
|
<p>精选全球最适合远程工作的城市,点击查看详情,勾选对比</p>
|
||||||
|
{onOpenMatcher && (
|
||||||
|
<button className="btn btn-primary dest-matcher-btn" onClick={onOpenMatcher}>
|
||||||
|
🎯 不知道去哪?智能匹配
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="dest-filters reveal">
|
||||||
|
<div className="filter-search">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
|
||||||
|
<input placeholder="搜索城市..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="filter-tags">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button key={f.key} className={`filter-btn${filter === f.key ? " active" : ""}`}
|
||||||
|
onClick={() => setFilter(f.key)}>{f.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="filter-sort">
|
||||||
|
<select value={sort} onChange={(e) => setSort(e.target.value)}>
|
||||||
|
<option value="rating">⭐ 评分排序</option>
|
||||||
|
<option value="cost-asc">💰 价格从低到高</option>
|
||||||
|
<option value="cost-desc">💰 价格从高到低</option>
|
||||||
|
<option value="speed">📶 网速排序</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dest-grid" id="dest-grid">
|
||||||
|
{filtered.map((d) => (
|
||||||
|
<article key={d.slug} className="dest-card reveal" style={{ position: "relative" }}>
|
||||||
|
{onCompare && (
|
||||||
|
<button className="compare-check" onClick={() => onCompare(d.slug)}
|
||||||
|
style={{
|
||||||
|
position: "absolute", top: 12, right: 12, zIndex: 2,
|
||||||
|
background: compareList.includes(d.slug) ? "var(--accent-3)" : "var(--bg-glass)",
|
||||||
|
border: "var(--border-glass)", borderRadius: "50%", width: 32, height: 32,
|
||||||
|
cursor: "pointer", fontSize: "0.8rem",
|
||||||
|
}}>
|
||||||
|
{compareList.includes(d.slug) ? "✓" : "⚖️"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link href={`/destinations/${d.slug}`} style={{ textDecoration: "none", color: "inherit" }}>
|
||||||
|
<div className="dest-img" style={{ "--hue": d.hue } as React.CSSProperties}>
|
||||||
|
<div className="dest-emoji">{d.emoji}</div>
|
||||||
|
<div className="dest-overlay"><span className="dest-tag">{d.tag}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="dest-body">
|
||||||
|
<h3>{d.name}, {d.country}</h3>
|
||||||
|
<p>{d.description}</p>
|
||||||
|
<div className="dest-meta">
|
||||||
|
<span>💰 ¥{d.cost.toLocaleString()}/月</span>
|
||||||
|
<span>📶 {d.speed}Mbps</span>
|
||||||
|
<span>🌡️ {d.temperature}°C</span>
|
||||||
|
</div>
|
||||||
|
<div className="dest-rating">
|
||||||
|
<span className="stars">{"⭐".repeat(Math.round(d.rating / 2))}</span>
|
||||||
|
<span>{d.rating} 分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div style={{ padding: "0 20px 20px", display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<div onClick={(e) => e.preventDefault()}>
|
||||||
|
<FavoriteButton slug={d.slug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="dest-empty">😢 没有找到匹配的目的地,试试其他筛选条件</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/FAQSection.tsx
Normal file
32
frontend/src/components/FAQSection.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { FAQ } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function FAQSection({ faqs }: { faqs: FAQ[] }) {
|
||||||
|
const [active, setActive] = useState<string | null>(null);
|
||||||
|
const sorted = [...faqs].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section faq-section" id="faq">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">❓ FAQ</span>
|
||||||
|
<h2>常见问题</h2>
|
||||||
|
<p>关于数字游民生活,你可能想知道的一切</p>
|
||||||
|
</div>
|
||||||
|
<div className="faq-list">
|
||||||
|
{sorted.map((f) => (
|
||||||
|
<div key={f.id} className={`faq-item reveal${active === f.id ? " active" : ""}`}>
|
||||||
|
<button className="faq-question" onClick={() => setActive(active === f.id ? null : f.id)}>
|
||||||
|
<span>{f.question}</span>
|
||||||
|
<svg className="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
<div className="faq-answer"><p>{f.answer}</p></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/components/FavoriteButton.tsx
Normal file
24
frontend/src/components/FavoriteButton.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function FavoriteButton({ slug }: { slug: string }) {
|
||||||
|
const { user, isFavorite, toggleFavorite } = useAuth();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Link href="/login" className="btn btn-ghost" style={{ fontSize: "0.85rem" }}>
|
||||||
|
🤍 登录收藏
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fav = isFavorite(slug);
|
||||||
|
return (
|
||||||
|
<button className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => toggleFavorite(slug)} style={{ fontSize: "0.85rem" }}>
|
||||||
|
{fav ? "❤️ 已收藏" : "🤍 收藏"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/components/Footer.tsx
Normal file
45
frontend/src/components/Footer.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="container">
|
||||||
|
<div className="footer-grid">
|
||||||
|
<div className="footer-brand">
|
||||||
|
<a href="#" className="logo"><span>NomadFlow</span></a>
|
||||||
|
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
|
||||||
|
<div className="social-links">
|
||||||
|
<a href="#" aria-label="Twitter">𝕏</a>
|
||||||
|
<a href="#" aria-label="Instagram">📷</a>
|
||||||
|
<a href="#" aria-label="YouTube">▶️</a>
|
||||||
|
<a href="#" aria-label="Discord">💬</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<h4>探索</h4>
|
||||||
|
<a href="#destinations">目的地</a>
|
||||||
|
<a href="#calculator">费用计算器</a>
|
||||||
|
<a href="#visa">签证指南</a>
|
||||||
|
<a href="#blog">博客</a>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<h4>技术栈</h4>
|
||||||
|
<a href="#">Next.js</a>
|
||||||
|
<a href="#">FastAPI</a>
|
||||||
|
<a href="#">PocketBase</a>
|
||||||
|
<a href="/api/docs" target="_blank">API 文档</a>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<h4>联系</h4>
|
||||||
|
<a href="#">关于我们</a>
|
||||||
|
<a href="#">合作伙伴</a>
|
||||||
|
<a href="#">隐私政策</a>
|
||||||
|
<a href="mailto:hello@nomadflow.io">hello@nomadflow.io</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="footer-bottom">
|
||||||
|
<p>© 2026 NomadFlow. Made with ❤️ for Digital Nomads everywhere.</p>
|
||||||
|
<p className="footer-emoji-bar">🌴 ☀️ 🏄♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/components/GlobalSearch.tsx
Normal file
129
frontend/src/components/GlobalSearch.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { SearchResult } from "@/lib/types";
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
destination: "目的地",
|
||||||
|
blog: "博客",
|
||||||
|
visa: "签证",
|
||||||
|
faq: "FAQ",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GlobalSearch() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (q: string) => {
|
||||||
|
if (!q.trim()) { setResults([]); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.search(q);
|
||||||
|
setResults(data);
|
||||||
|
setActiveIdx(0);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => doSearch(query), 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query, doSearch]);
|
||||||
|
|
||||||
|
const navigate = (url: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
if (url.startsWith("/#")) {
|
||||||
|
router.push("/");
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector(url.replace("/", ""))?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, results.length - 1)); }
|
||||||
|
if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
|
||||||
|
if (e.key === "Enter" && results[activeIdx]) navigate(results[activeIdx].url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="search-trigger" onClick={() => setOpen(true)} aria-label="搜索">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
<span>搜索...</span>
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="search-overlay" onClick={() => setOpen(false)}>
|
||||||
|
<div className="search-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="search-input-wrap">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
placeholder="搜索目的地、博客、签证..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
<button onClick={() => setOpen(false)}>ESC</button>
|
||||||
|
</div>
|
||||||
|
<div className="search-results">
|
||||||
|
{loading && <p className="search-empty">搜索中...</p>}
|
||||||
|
{!loading && query && results.length === 0 && (
|
||||||
|
<p className="search-empty">😢 没有找到「{query}」相关结果</p>
|
||||||
|
)}
|
||||||
|
{!loading && results.map((r, i) => (
|
||||||
|
<button key={`${r.type}-${r.url}`}
|
||||||
|
className={`search-result${i === activeIdx ? " active" : ""}`}
|
||||||
|
onClick={() => navigate(r.url)}
|
||||||
|
onMouseEnter={() => setActiveIdx(i)}>
|
||||||
|
<span className="search-result-emoji">{r.emoji}</span>
|
||||||
|
<div className="search-result-text">
|
||||||
|
<strong>{r.title}</strong>
|
||||||
|
<span>{r.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
<span className="search-result-type">{TYPE_LABELS[r.type] || r.type}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!query && (
|
||||||
|
<div className="search-hints">
|
||||||
|
<p>💡 试试搜索:清迈、签证、税务</p>
|
||||||
|
<p>⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
frontend/src/components/Hero.tsx
Normal file
144
frontend/src/components/Hero.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Stats } from "@/lib/types";
|
||||||
|
|
||||||
|
const PHRASES = [
|
||||||
|
"数字游民不是逃离生活,而是用科技重新定义生活。",
|
||||||
|
"在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️",
|
||||||
|
"世界那么大,你的办公室可以在任何地方 🌍",
|
||||||
|
"远程工作 + 全球旅居 = 无限可能 ✨",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props { stats: Stats }
|
||||||
|
|
||||||
|
export default function Hero({ stats }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
const el = document.getElementById("typewriter");
|
||||||
|
if (!el) return;
|
||||||
|
let phraseIdx = 0, charIdx = 0, deleting = false, timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const type = () => {
|
||||||
|
const current = PHRASES[phraseIdx];
|
||||||
|
if (!deleting) {
|
||||||
|
el.textContent = current.substring(0, charIdx + 1);
|
||||||
|
charIdx++;
|
||||||
|
if (charIdx === current.length) { deleting = true; timer = setTimeout(type, 2500); return; }
|
||||||
|
timer = setTimeout(type, 60);
|
||||||
|
} else {
|
||||||
|
el.textContent = current.substring(0, charIdx - 1);
|
||||||
|
charIdx--;
|
||||||
|
if (charIdx === 0) { deleting = false; phraseIdx = (phraseIdx + 1) % PHRASES.length; }
|
||||||
|
timer = setTimeout(type, 30);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timer = setTimeout(type, 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const counters = document.querySelectorAll(".stat-number");
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
const el = entry.target as HTMLElement;
|
||||||
|
const target = parseInt(el.dataset.target || "0", 10);
|
||||||
|
const start = performance.now();
|
||||||
|
const step = (now: number) => {
|
||||||
|
const p = Math.min((now - start) / 2000, 1);
|
||||||
|
el.textContent = String(Math.floor((1 - Math.pow(1 - p, 3)) * target));
|
||||||
|
if (p < 1) requestAnimationFrame(step);
|
||||||
|
else el.textContent = String(target);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
observer.unobserve(el);
|
||||||
|
});
|
||||||
|
}, { threshold: 0.5 });
|
||||||
|
counters.forEach((c) => observer.observe(c));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="hero" id="hero">
|
||||||
|
<div className="hero-bg-shapes">
|
||||||
|
<div className="shape shape-1" /><div className="shape shape-2" /><div className="shape shape-3" />
|
||||||
|
</div>
|
||||||
|
<div className="hero-content">
|
||||||
|
<div className="hero-badge reveal">
|
||||||
|
<span className="badge-dot" />
|
||||||
|
🚀 全球 {stats.total_nomads} 数字游民正在路上
|
||||||
|
</div>
|
||||||
|
<h1 className="hero-title reveal">
|
||||||
|
用一行代码<br /><span className="gradient-text">环游世界</span> 🌏
|
||||||
|
</h1>
|
||||||
|
<p className="hero-subtitle reveal">
|
||||||
|
<span id="typewriter" /><span className="typewriter-cursor">|</span>
|
||||||
|
</p>
|
||||||
|
<div className="hero-actions reveal">
|
||||||
|
<a href="#destinations" className="btn btn-primary">
|
||||||
|
<span>探索目的地</span>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||||
|
</a>
|
||||||
|
<a href="#calculator" className="btn btn-ghost"><span>🧮 费用计算</span></a>
|
||||||
|
<button className="btn btn-ghost hero-matcher-btn" onClick={() => {
|
||||||
|
window.dispatchEvent(new CustomEvent("open-matcher"));
|
||||||
|
}}><span>🎯 智能匹配</span></button>
|
||||||
|
</div>
|
||||||
|
<div className="hero-stats reveal">
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-number" data-target={stats.countries}>0</span>
|
||||||
|
<span className="stat-label">🗺️ 可探索国家</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-divider" />
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-number" data-target={stats.avg_cost}>0</span>
|
||||||
|
<span className="stat-label">💰 平均月生活费 (千元)</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-divider" />
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-number" data-target={stats.satisfaction}>0</span>
|
||||||
|
<span className="stat-label">😊 生活满意度 %</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-visual reveal">
|
||||||
|
<div className="globe-container">
|
||||||
|
<svg className="globe-svg" viewBox="0 0 400 400">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="globeGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="#4ECDC4" stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor="#4ECDC4" stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="orbitGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#FF6B6B" /><stop offset="100%" stopColor="#4ECDC4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="200" cy="200" r="160" fill="url(#globeGlow)" />
|
||||||
|
<circle cx="200" cy="200" r="120" fill="none" stroke="rgba(78,205,196,0.2)" strokeWidth="1" />
|
||||||
|
<ellipse cx="200" cy="200" rx="120" ry="40" fill="none" stroke="rgba(78,205,196,0.3)" strokeWidth="1.5" className="orbit-ring" />
|
||||||
|
<ellipse cx="200" cy="200" rx="40" ry="120" fill="none" stroke="rgba(255,107,107,0.3)" strokeWidth="1.5" className="orbit-ring orbit-ring-2" />
|
||||||
|
<path d="M160 140 Q180 130 200 135 Q220 128 240 140 Q250 155 245 170 Q235 185 220 190 Q200 195 185 188 Q165 180 160 165 Z" fill="rgba(78,205,196,0.4)" className="continent" />
|
||||||
|
<g className="nomad-dots">
|
||||||
|
<circle cx="180" cy="155" r="5" fill="#FF6B6B" className="pulse-dot" />
|
||||||
|
<circle cx="260" cy="215" r="5" fill="#FFE66D" className="pulse-dot" />
|
||||||
|
<circle cx="145" cy="225" r="5" fill="#4ECDC4" className="pulse-dot" />
|
||||||
|
<circle cx="220" cy="175" r="5" fill="#A78BFA" className="pulse-dot" />
|
||||||
|
</g>
|
||||||
|
<path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" strokeWidth="2" strokeDasharray="6 4" className="flight-path" />
|
||||||
|
</svg>
|
||||||
|
<div className="floating-cards">
|
||||||
|
<div className="float-card fc-1">🏝️ 巴厘岛<br /><small>WiFi 98Mbps</small></div>
|
||||||
|
<div className="float-card fc-2">☕ 里斯本<br /><small>€800/月</small></div>
|
||||||
|
<div className="float-card fc-3">🏔️ 清迈<br /><small>⭐ 4.9</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="scroll-indicator">
|
||||||
|
<span>向下滚动</span>
|
||||||
|
<div className="scroll-arrow">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
frontend/src/components/HomeClient.tsx
Normal file
132
frontend/src/components/HomeClient.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
|
||||||
|
import Loader from "./Loader";
|
||||||
|
import ParticleCanvas from "./ParticleCanvas";
|
||||||
|
import Navbar from "./Navbar";
|
||||||
|
import TickerBar from "./TickerBar";
|
||||||
|
import Hero from "./Hero";
|
||||||
|
import WorldMap from "./WorldMap";
|
||||||
|
import Destinations from "./Destinations";
|
||||||
|
import Calculator from "./Calculator";
|
||||||
|
import TripPlanner from "./TripPlanner";
|
||||||
|
import GlobalSearch from "./GlobalSearch";
|
||||||
|
import Lifestyle from "./Lifestyle";
|
||||||
|
import Charts from "./Charts";
|
||||||
|
import VisaSection from "./VisaSection";
|
||||||
|
import Tools from "./Tools";
|
||||||
|
import BlogSection from "./BlogSection";
|
||||||
|
import FAQSection from "./FAQSection";
|
||||||
|
import Community from "./Community";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
import CompareModal from "./CompareModal";
|
||||||
|
import DestinationMatcher, { MatcherTrigger } from "./DestinationMatcher";
|
||||||
|
import TimezoneBoard from "./TimezoneBoard";
|
||||||
|
import ScrollProgress from "./ScrollProgress";
|
||||||
|
import SectionNav from "./SectionNav";
|
||||||
|
import CurrencyConverter from "./CurrencyConverter";
|
||||||
|
import NomadScore from "./NomadScore";
|
||||||
|
import KeyboardShortcuts from "./KeyboardShortcuts";
|
||||||
|
import BackToTop from "./BackToTop";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stats: Stats;
|
||||||
|
ticker: string[];
|
||||||
|
destinations: Destination[];
|
||||||
|
visas: Visa[];
|
||||||
|
faqs: FAQ[];
|
||||||
|
testimonials: Testimonial[];
|
||||||
|
tools: Tool[];
|
||||||
|
blog: BlogPost[];
|
||||||
|
charts: { cost: ChartData; speed: ChartData; growth: ChartData; radar: ChartData };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeClient(props: Props) {
|
||||||
|
const [compareList, setCompareList] = useState<string[]>([]);
|
||||||
|
const [compareResults, setCompareResults] = useState<Destination[] | null>(null);
|
||||||
|
const [compareOpen, setCompareOpen] = useState(false);
|
||||||
|
const [matcherOpen, setMatcherOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMatcher = () => setMatcherOpen(true);
|
||||||
|
window.addEventListener("open-matcher", onMatcher);
|
||||||
|
return () => window.removeEventListener("open-matcher", onMatcher);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reveals = document.querySelectorAll(".reveal");
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => entries.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("visible"); observer.unobserve(e.target); } }),
|
||||||
|
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" }
|
||||||
|
);
|
||||||
|
reveals.forEach((el, i) => {
|
||||||
|
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.1}s`;
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCompare = (slug: string) => {
|
||||||
|
setCompareList((prev) => {
|
||||||
|
if (prev.includes(slug)) return prev.filter((s) => s !== slug);
|
||||||
|
if (prev.length >= 4) return prev;
|
||||||
|
return [...prev, slug];
|
||||||
|
});
|
||||||
|
setCompareResults(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCompare = async () => {
|
||||||
|
if (compareList.length < 2) return;
|
||||||
|
try {
|
||||||
|
const data = await api.compareDestinations(compareList);
|
||||||
|
setCompareResults(data);
|
||||||
|
setCompareOpen(true);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollProgress />
|
||||||
|
<Loader />
|
||||||
|
<ParticleCanvas />
|
||||||
|
<Navbar />
|
||||||
|
<GlobalSearch />
|
||||||
|
<TickerBar messages={props.ticker} />
|
||||||
|
<Hero stats={props.stats} />
|
||||||
|
<WorldMap destinations={props.destinations} />
|
||||||
|
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
|
||||||
|
<TimezoneBoard destinations={props.destinations} />
|
||||||
|
<TripPlanner destinations={props.destinations} />
|
||||||
|
<Calculator destinations={props.destinations} />
|
||||||
|
<CurrencyConverter />
|
||||||
|
<NomadScore />
|
||||||
|
<Lifestyle />
|
||||||
|
<Charts {...props.charts} />
|
||||||
|
<VisaSection visas={props.visas} />
|
||||||
|
<Tools tools={props.tools} />
|
||||||
|
<BlogSection posts={props.blog} />
|
||||||
|
<FAQSection faqs={props.faqs} />
|
||||||
|
<Community testimonials={props.testimonials} />
|
||||||
|
<Footer />
|
||||||
|
<BackToTop />
|
||||||
|
<SectionNav />
|
||||||
|
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
|
||||||
|
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
|
||||||
|
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
||||||
|
|
||||||
|
{compareList.length > 0 && (
|
||||||
|
<div className="compare-bar">
|
||||||
|
<span>⚖️ 已选 {compareList.length}/4</span>
|
||||||
|
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>对比城市</button>
|
||||||
|
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{compareOpen && compareResults && (
|
||||||
|
<CompareModal destinations={compareResults} onClose={() => setCompareOpen(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
frontend/src/components/KeyboardShortcuts.tsx
Normal file
65
frontend/src/components/KeyboardShortcuts.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onOpenMatcher?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORTCUTS = [
|
||||||
|
{ keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
|
||||||
|
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
|
||||||
|
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
|
||||||
|
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
|
||||||
|
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
|
||||||
|
{ keys: ["Enter"], label: "确认选择", icon: "↵" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
|
const typing = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
||||||
|
|
||||||
|
if (e.key === "?" && !typing && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
if (e.key === "m" && !typing && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpenMatcher?.();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && open) setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onOpenMatcher]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay open shortcuts-overlay" onClick={(e) => e.target === e.currentTarget && setOpen(false)}>
|
||||||
|
<div className="modal shortcuts-modal">
|
||||||
|
<button className="modal-close" onClick={() => setOpen(false)} aria-label="关闭">✕</button>
|
||||||
|
<div className="shortcuts-body">
|
||||||
|
<span className="section-tag">⌨️ SHORTCUTS</span>
|
||||||
|
<h2>键盘快捷键</h2>
|
||||||
|
<p>提升浏览效率,按 <kbd>?</kbd> 随时唤起</p>
|
||||||
|
<div className="shortcuts-grid">
|
||||||
|
{SHORTCUTS.map((s) => (
|
||||||
|
<div key={s.label} className="shortcut-item">
|
||||||
|
<span className="shortcut-icon">{s.icon}</span>
|
||||||
|
<span className="shortcut-label">{s.label}</span>
|
||||||
|
<div className="shortcut-keys">
|
||||||
|
{s.keys.map((k) => <kbd key={k}>{k}</kbd>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/src/components/Lifestyle.tsx
Normal file
81
frontend/src/components/Lifestyle.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ time: "🌅 07:00", icon: "🧘", title: "晨间仪式", desc: "瑜伽、冥想或海边跑步,用身心唤醒开启新的一天", tip: "推荐:清迈河边晨跑、巴厘岛海滩瑜伽" },
|
||||||
|
{ time: "☕ 09:00", icon: "💻", title: "深度工作", desc: "在 Co-working Space 或咖啡馆专注 4 小时,完成核心任务", tip: "推荐:Punspace 清迈、Outsite 里斯本" },
|
||||||
|
{ time: "🍜 13:00", icon: "🍽️", title: "本地探索", desc: "品尝地道美食,与当地人交流,感受文化碰撞", tip: "推荐:清迈夜市、巴塞罗那 Tapas 之旅" },
|
||||||
|
{ time: "🌇 17:00", icon: "📞", title: "跨时区协作", desc: "与全球团队视频会议,灵活安排跨时区沟通", tip: "工具:Calendly 排时区、World Time Buddy" },
|
||||||
|
{ time: "🌙 20:00", icon: "🎉", title: "社群社交", desc: "参加 Nomad Meetup,结识来自世界各地的同行者", tip: "平台:Nomad List Meetups、Facebook 群组" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Lifestyle() {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section lifestyle" id="lifestyle">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">💻 NOMAD LIFE</span>
|
||||||
|
<h2>数字游民的一天</h2>
|
||||||
|
<p>自由不等于散漫,高效工作才能尽情探索 — 点击卡片查看详情</p>
|
||||||
|
</div>
|
||||||
|
<div className="lifestyle-layout reveal">
|
||||||
|
<div className="timeline">
|
||||||
|
<div className="timeline-line">
|
||||||
|
<svg viewBox="0 0 4 600" preserveAspectRatio="none">
|
||||||
|
<line x1="2" y1="0" x2="2" y2="600" stroke="url(#timelineGrad)" strokeWidth="3" strokeDasharray="8 6" className="timeline-svg-line" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="timelineGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#FF6B6B" /><stop offset="50%" stopColor="#FFE66D" /><stop offset="100%" stopColor="#4ECDC4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{ITEMS.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={item.time}
|
||||||
|
className={`timeline-item${active === i ? " active" : ""}`}
|
||||||
|
onClick={() => setActive(i)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && setActive(i)}
|
||||||
|
>
|
||||||
|
<div className="timeline-time">{item.time}</div>
|
||||||
|
<div className="timeline-card">
|
||||||
|
<div className="timeline-icon">{item.icon}</div>
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
<p>{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lifestyle-detail">
|
||||||
|
<div className="lifestyle-detail-card">
|
||||||
|
<span className="lifestyle-detail-icon">{ITEMS[active].icon}</span>
|
||||||
|
<span className="lifestyle-detail-time">{ITEMS[active].time}</span>
|
||||||
|
<h3>{ITEMS[active].title}</h3>
|
||||||
|
<p>{ITEMS[active].desc}</p>
|
||||||
|
<div className="lifestyle-tip">
|
||||||
|
<span>💡</span>
|
||||||
|
<p>{ITEMS[active].tip}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lifestyle-dots">
|
||||||
|
{ITEMS.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`lifestyle-dot${active === i ? " active" : ""}`}
|
||||||
|
onClick={() => setActive(i)}
|
||||||
|
aria-label={`第 ${i + 1} 项`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/Loader.tsx
Normal file
32
frontend/src/components/Loader.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function Loader() {
|
||||||
|
useEffect(() => {
|
||||||
|
const loader = document.getElementById("loader");
|
||||||
|
const hide = () => loader?.classList.add("hidden");
|
||||||
|
window.addEventListener("load", () => setTimeout(hide, 1800));
|
||||||
|
const t = setTimeout(hide, 3000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="loader" id="loader">
|
||||||
|
<div className="loader-inner">
|
||||||
|
<svg className="loader-globe" viewBox="0 0 80 80" fill="none">
|
||||||
|
<circle cx="40" cy="40" r="35" stroke="url(#loaderGrad)" strokeWidth="3" strokeDasharray="60 160" className="loader-ring" />
|
||||||
|
<text x="40" y="48" textAnchor="middle" fontSize="24">🌍</text>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="loaderGrad" x1="0" y1="0" x2="80" y2="80">
|
||||||
|
<stop offset="0%" stopColor="#FF6B6B" />
|
||||||
|
<stop offset="100%" stopColor="#4ECDC4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<p className="loader-text">NomadFlow</p>
|
||||||
|
<div className="loader-bar"><div className="loader-progress" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
frontend/src/components/Navbar.tsx
Normal file
103
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
const LINKS = [
|
||||||
|
{ href: "/#map", label: "🗺️ 地图", section: "map" },
|
||||||
|
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" },
|
||||||
|
{ href: "/#trip", label: "🗓️ 行程", section: "trip" },
|
||||||
|
{ href: "/#timezone", label: "🕐 时区", section: "timezone" },
|
||||||
|
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" },
|
||||||
|
{ href: "/#visa", label: "📋 签证", section: "visa" },
|
||||||
|
{ href: "/#blog", label: "📝 博客", section: "blog" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isHome = pathname === "/";
|
||||||
|
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [active, setActive] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("nomad-theme");
|
||||||
|
if (saved === "light") {
|
||||||
|
setTheme("light");
|
||||||
|
document.documentElement.setAttribute("data-theme", "light");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const navbar = document.getElementById("navbar");
|
||||||
|
const onScroll = () => navbar?.classList.toggle("scrolled", window.scrollY > 50);
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHome) return;
|
||||||
|
const sections = LINKS.map((l) => document.getElementById(l.section)).filter(Boolean);
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
|
||||||
|
{ threshold: 0.3, rootMargin: "-80px 0px -50% 0px" }
|
||||||
|
);
|
||||||
|
sections.forEach((s) => observer.observe(s!));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isHome]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = theme === "dark" ? "light" : "dark";
|
||||||
|
setTheme(next);
|
||||||
|
if (next === "light") document.documentElement.setAttribute("data-theme", "light");
|
||||||
|
else document.documentElement.removeAttribute("data-theme");
|
||||||
|
localStorage.setItem("nomad-theme", next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="navbar" id="navbar">
|
||||||
|
<div className="nav-container">
|
||||||
|
<Link href="/" className="logo">
|
||||||
|
<svg className="logo-icon" viewBox="0 0 40 40" fill="none">
|
||||||
|
<circle cx="20" cy="20" r="18" stroke="url(#logoGrad)" strokeWidth="2.5" />
|
||||||
|
<path d="M12 22c4-8 12-8 16 0" stroke="url(#logoGrad)" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<circle cx="28" cy="14" r="3" fill="url(#logoGrad)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="logoGrad" x1="0" y1="0" x2="40" y2="40">
|
||||||
|
<stop offset="0%" stopColor="#FF6B6B" />
|
||||||
|
<stop offset="50%" stopColor="#FFE66D" />
|
||||||
|
<stop offset="100%" stopColor="#4ECDC4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<span>NomadFlow</span>
|
||||||
|
</Link>
|
||||||
|
<nav className={`nav-links${menuOpen ? " open" : ""}`}>
|
||||||
|
{LINKS.map((l) => (
|
||||||
|
<Link key={l.section} href={l.href} data-section={l.section}
|
||||||
|
className={isHome && active === l.section ? "active" : ""}
|
||||||
|
onClick={() => setMenuOpen(false)}>{l.label}</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<button className="nav-cta" onClick={toggleTheme} aria-label="切换主题">
|
||||||
|
<span className="theme-icon">{theme === "dark" ? "🌙" : "☀️"}</span>
|
||||||
|
</button>
|
||||||
|
{user ? (
|
||||||
|
<Link href="/profile" className="nav-user">
|
||||||
|
<span>{user.avatar}</span>
|
||||||
|
<span className="nav-user-name">{user.name}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/login" className="nav-login-btn">登录</Link>
|
||||||
|
)}
|
||||||
|
<button className="mobile-menu-btn" onClick={() => setMenuOpen(!menuOpen)} aria-label="菜单">
|
||||||
|
<span /><span /><span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/components/NomadScore.tsx
Normal file
142
frontend/src/components/NomadScore.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const QUESTIONS = [
|
||||||
|
{
|
||||||
|
q: "你的远程工作收入是否稳定?",
|
||||||
|
options: [
|
||||||
|
{ label: "非常稳定,有固定客户/薪资", score: 25, emoji: "💪" },
|
||||||
|
{ label: "基本稳定,偶尔波动", score: 18, emoji: "👍" },
|
||||||
|
{ label: "还在起步阶段", score: 8, emoji: "🌱" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "你的月预算大约多少?",
|
||||||
|
options: [
|
||||||
|
{ label: "¥5,000 以下", score: 20, emoji: "💰" },
|
||||||
|
{ label: "¥5,000 – 10,000", score: 22, emoji: "💳" },
|
||||||
|
{ label: "¥10,000 以上", score: 25, emoji: "💎" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "你对签证/税务了解多少?",
|
||||||
|
options: [
|
||||||
|
{ label: "已研究清楚,有方案", score: 25, emoji: "📋" },
|
||||||
|
{ label: "了解基础,还需深入", score: 15, emoji: "📖" },
|
||||||
|
{ label: "完全不了解", score: 5, emoji: "❓" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "你的英语/沟通能力如何?",
|
||||||
|
options: [
|
||||||
|
{ label: "流利,可无障碍交流", score: 25, emoji: "🗣️" },
|
||||||
|
{ label: "基础够用,能应对日常", score: 15, emoji: "💬" },
|
||||||
|
{ label: "需要提升", score: 8, emoji: "📚" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "你愿意离开舒适区吗?",
|
||||||
|
options: [
|
||||||
|
{ label: "非常期待新体验", score: 25, emoji: "🚀" },
|
||||||
|
{ label: "愿意尝试,略有顾虑", score: 18, emoji: "🌤️" },
|
||||||
|
{ label: "更喜欢熟悉的环境", score: 8, emoji: "🏠" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEVELS = [
|
||||||
|
{ min: 0, max: 40, label: "🌱 萌芽游民", desc: "建议先从短期旅居开始,选择清迈或巴厘岛积累经验", color: "#FF6B6B" },
|
||||||
|
{ min: 41, max: 70, label: "🌤️ 准游民", desc: "你已经具备基础条件,可以规划 3-6 个月的旅居试点", color: "#FFE66D" },
|
||||||
|
{ min: 71, max: 90, label: "🚀 就绪游民", desc: "条件成熟!可以开始规划你的环球旅居之旅了", color: "#4ECDC4" },
|
||||||
|
{ min: 91, max: 100, label: "👑 资深游民", desc: "你就是为这种生活而生的!随时可以出发", color: "#A78BFA" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NomadScore() {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [scores, setScores] = useState<number[]>([]);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const totalScore = scores.reduce((a, b) => a + b, 0);
|
||||||
|
const maxScore = QUESTIONS.length * 25;
|
||||||
|
const pct = Math.round((totalScore / maxScore) * 100);
|
||||||
|
const level = LEVELS.find((l) => pct >= l.min && pct <= l.max) || LEVELS[LEVELS.length - 1];
|
||||||
|
|
||||||
|
const select = (score: number) => {
|
||||||
|
setAnimating(true);
|
||||||
|
const next = [...scores, score];
|
||||||
|
setScores(next);
|
||||||
|
setTimeout(() => {
|
||||||
|
setAnimating(false);
|
||||||
|
if (step < QUESTIONS.length - 1) setStep(step + 1);
|
||||||
|
else setDone(true);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep(0);
|
||||||
|
setScores([]);
|
||||||
|
setDone(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section nomad-score-section" id="nomad-score">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">📊 READINESS</span>
|
||||||
|
<h2>游民就绪度测评</h2>
|
||||||
|
<p>5 个问题,测测你离数字游民生活还有多远</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nomad-score-card reveal">
|
||||||
|
{!done ? (
|
||||||
|
<div className={`nomad-quiz${animating ? " animating" : ""}`}>
|
||||||
|
<div className="nomad-quiz-progress">
|
||||||
|
{QUESTIONS.map((_, i) => (
|
||||||
|
<div key={i} className={`nomad-quiz-dot${i <= step ? " filled" : ""}${i === step ? " current" : ""}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="nomad-quiz-step">问题 {step + 1} / {QUESTIONS.length}</span>
|
||||||
|
<h3>{QUESTIONS[step].q}</h3>
|
||||||
|
<div className="nomad-quiz-options">
|
||||||
|
{QUESTIONS[step].options.map((opt) => (
|
||||||
|
<button key={opt.label} className="nomad-quiz-option" onClick={() => select(opt.score)}>
|
||||||
|
<span>{opt.emoji}</span>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="nomad-result">
|
||||||
|
<div className="nomad-score-ring" style={{ "--score-color": level.color } as React.CSSProperties}>
|
||||||
|
<svg viewBox="0 0 120 120">
|
||||||
|
<circle cx="60" cy="60" r="52" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="8" />
|
||||||
|
<circle
|
||||||
|
cx="60" cy="60" r="52" fill="none"
|
||||||
|
stroke={level.color} strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${pct * 3.27} 327`}
|
||||||
|
transform="rotate(-90 60 60)"
|
||||||
|
className="nomad-score-arc"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="nomad-score-center">
|
||||||
|
<strong>{pct}</strong>
|
||||||
|
<span>就绪度</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="nomad-level-label">{level.label}</h3>
|
||||||
|
<p className="nomad-level-desc">{level.desc}</p>
|
||||||
|
<div className="nomad-result-actions">
|
||||||
|
<button className="btn btn-ghost" onClick={reset}>🔄 重新测评</button>
|
||||||
|
<a href="#destinations" className="btn btn-primary">探索目的地 🌍</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/components/ParticleCanvas.tsx
Normal file
78
frontend/src/components/ParticleCanvas.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export default function ParticleCanvas() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animId: number;
|
||||||
|
let particles: {
|
||||||
|
x: number; y: number; size: number;
|
||||||
|
speedX: number; speedY: number; opacity: number; hue: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
const count = Math.min(Math.floor(window.innerWidth / 12), 120);
|
||||||
|
particles = Array.from({ length: count }, () => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
size: Math.random() * 2 + 0.5,
|
||||||
|
speedX: (Math.random() - 0.5) * 0.3,
|
||||||
|
speedY: (Math.random() - 0.5) * 0.3,
|
||||||
|
opacity: Math.random() * 0.5 + 0.1,
|
||||||
|
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
particles.forEach((p, i) => {
|
||||||
|
p.x += p.speedX;
|
||||||
|
p.y += p.speedY;
|
||||||
|
if (p.x < 0) p.x = canvas.width;
|
||||||
|
if (p.x > canvas.width) p.x = 0;
|
||||||
|
if (p.y < 0) p.y = canvas.height;
|
||||||
|
if (p.y > canvas.height) p.y = 0;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
particles.slice(i + 1).forEach((p2) => {
|
||||||
|
const dx = p.x - p2.x;
|
||||||
|
const dy = p.y - p2.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
ctx.lineTo(p2.x, p2.y);
|
||||||
|
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
create();
|
||||||
|
draw();
|
||||||
|
window.addEventListener("resize", () => { resize(); create(); });
|
||||||
|
return () => { cancelAnimationFrame(animId); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <canvas id="particle-canvas" ref={canvasRef} aria-hidden="true" />;
|
||||||
|
}
|
||||||
24
frontend/src/components/ScrollProgress.tsx
Normal file
24
frontend/src/components/ScrollProgress.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function ScrollProgress() {
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
setProgress(docHeight > 0 ? Math.min((scrollTop / docHeight) * 100, 100) : 0);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scroll-progress" aria-hidden="true">
|
||||||
|
<div className="scroll-progress-bar" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/components/SectionNav.tsx
Normal file
57
frontend/src/components/SectionNav.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: "map", emoji: "🗺️", label: "地图" },
|
||||||
|
{ id: "destinations", emoji: "🌍", label: "目的地" },
|
||||||
|
{ id: "timezone", emoji: "🕐", label: "时区" },
|
||||||
|
{ id: "trip", emoji: "🗓️", label: "行程" },
|
||||||
|
{ id: "calculator", emoji: "🧮", label: "计算器" },
|
||||||
|
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
||||||
|
{ id: "visa", emoji: "📋", label: "签证" },
|
||||||
|
{ id: "blog", emoji: "📝", label: "博客" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SectionNav() {
|
||||||
|
const [active, setActive] = useState("");
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setVisible(window.scrollY > 400);
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sections = SECTIONS.map((s) => document.getElementById(s.id)).filter(Boolean);
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }),
|
||||||
|
{ threshold: 0.25, rootMargin: "-80px 0px -55% 0px" }
|
||||||
|
);
|
||||||
|
sections.forEach((s) => observer.observe(s!));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollTo = (id: string) => {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={`section-nav${visible ? " visible" : ""}`} aria-label="章节导航">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
className={`section-nav-dot${active === s.id ? " active" : ""}`}
|
||||||
|
onClick={() => scrollTo(s.id)}
|
||||||
|
aria-label={s.label}
|
||||||
|
title={s.label}
|
||||||
|
>
|
||||||
|
<span className="section-nav-emoji">{s.emoji}</span>
|
||||||
|
<span className="section-nav-label">{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/components/SiteShell.tsx
Normal file
18
frontend/src/components/SiteShell.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Navbar from "./Navbar";
|
||||||
|
import ParticleCanvas from "./ParticleCanvas";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
import GlobalSearch from "./GlobalSearch";
|
||||||
|
|
||||||
|
export default function SiteShell({ children, showFooter = false }: { children: React.ReactNode; showFooter?: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ParticleCanvas />
|
||||||
|
<Navbar />
|
||||||
|
<GlobalSearch />
|
||||||
|
{children}
|
||||||
|
{showFooter && <Footer />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/src/components/TickerBar.tsx
Normal file
12
frontend/src/components/TickerBar.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
interface Props { messages: string[] }
|
||||||
|
|
||||||
|
export default function TickerBar({ messages }: Props) {
|
||||||
|
const items = [...messages, ...messages];
|
||||||
|
return (
|
||||||
|
<div className="ticker-bar">
|
||||||
|
<div className="ticker-track">
|
||||||
|
{items.map((msg, i) => <span key={i}>{msg}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
frontend/src/components/TimezoneBoard.tsx
Normal file
176
frontend/src/components/TimezoneBoard.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const TZ_OFFSETS: Record<string, number> = {
|
||||||
|
bali: 8,
|
||||||
|
lisbon: 0,
|
||||||
|
chiangmai: 7,
|
||||||
|
mexico: -6,
|
||||||
|
barcelona: 1,
|
||||||
|
tokyo: 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOME_TZ = 8; // China UTC+8
|
||||||
|
const WORK_START = 9;
|
||||||
|
const WORK_END = 18;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
destinations: Destination[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalHour(utcOffset: number): number {
|
||||||
|
const now = new Date();
|
||||||
|
const utc = now.getUTCHours() + now.getUTCMinutes() / 60;
|
||||||
|
return Math.floor((utc + utcOffset + 24) % 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(hour: number, min: number): string {
|
||||||
|
return `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverlapHours(destOffset: number, homeStart: number, homeEnd: number): number {
|
||||||
|
let overlap = 0;
|
||||||
|
for (let h = homeStart; h < homeEnd; h++) {
|
||||||
|
const destHour = (h - HOME_TZ + destOffset + 24) % 24;
|
||||||
|
if (destHour >= 8 && destHour < 20) overlap++;
|
||||||
|
}
|
||||||
|
return overlap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapLabel(hours: number): { text: string; class: string } {
|
||||||
|
if (hours >= 7) return { text: "极佳", class: "excellent" };
|
||||||
|
if (hours >= 5) return { text: "良好", class: "good" };
|
||||||
|
if (hours >= 3) return { text: "一般", class: "fair" };
|
||||||
|
return { text: "困难", class: "poor" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimezoneBoard({ destinations }: Props) {
|
||||||
|
const [now, setNow] = useState(new Date());
|
||||||
|
const [homeStart, setHomeStart] = useState(WORK_START);
|
||||||
|
const [homeEnd, setHomeEnd] = useState(WORK_END);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cities = useMemo(() =>
|
||||||
|
destinations
|
||||||
|
.filter((d) => TZ_OFFSETS[d.slug] !== undefined)
|
||||||
|
.map((d) => {
|
||||||
|
const offset = TZ_OFFSETS[d.slug];
|
||||||
|
const localHour = getLocalHour(offset);
|
||||||
|
const overlap = getOverlapHours(offset, homeStart, homeEnd);
|
||||||
|
const label = overlapLabel(overlap);
|
||||||
|
const isWorkTime = localHour >= 9 && localHour < 18;
|
||||||
|
return { ...d, offset, localHour, overlap, label, isWorkTime };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.overlap - a.overlap),
|
||||||
|
[destinations, homeStart, homeEnd]);
|
||||||
|
|
||||||
|
const homeTime = formatTime(now.getHours(), now.getMinutes());
|
||||||
|
const homeSeconds = now.getSeconds();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section timezone-section" id="timezone">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🕐 TIMEZONE</span>
|
||||||
|
<h2>时区工作看板</h2>
|
||||||
|
<p>实时查看各城市当地时间,评估与国内团队的工作时间重叠</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tz-home-clock reveal">
|
||||||
|
<div className="tz-home-time">
|
||||||
|
<span className="tz-live-dot" />
|
||||||
|
<span className="tz-home-label">🇨🇳 北京时间</span>
|
||||||
|
<span className="tz-home-digits">
|
||||||
|
{homeTime}<span className="tz-seconds">:{String(homeSeconds).padStart(2, "0")}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="tz-work-slider">
|
||||||
|
<label>⏰ 你的工作时间: {homeStart}:00 – {homeEnd}:00</label>
|
||||||
|
<div className="tz-slider-row">
|
||||||
|
<span>开始</span>
|
||||||
|
<input type="range" min={6} max={12} value={homeStart}
|
||||||
|
onChange={(e) => setHomeStart(Math.min(+e.target.value, homeEnd - 1))} />
|
||||||
|
<span>{homeStart}:00</span>
|
||||||
|
</div>
|
||||||
|
<div className="tz-slider-row">
|
||||||
|
<span>结束</span>
|
||||||
|
<input type="range" min={13} max={22} value={homeEnd}
|
||||||
|
onChange={(e) => setHomeEnd(Math.max(+e.target.value, homeStart + 1))} />
|
||||||
|
<span>{homeEnd}:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tz-grid reveal">
|
||||||
|
{cities.map((city) => {
|
||||||
|
const localMin = now.getUTCMinutes();
|
||||||
|
const localTime = formatTime(city.localHour, localMin);
|
||||||
|
const angle = ((city.localHour % 12) + localMin / 60) * 30;
|
||||||
|
return (
|
||||||
|
<div key={city.slug} className={`tz-card${city.isWorkTime ? " working" : ""}`}>
|
||||||
|
<div className="tz-card-header">
|
||||||
|
<span className="tz-card-emoji">{city.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{city.name}</strong>
|
||||||
|
<span>UTC{city.offset >= 0 ? "+" : ""}{city.offset}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`tz-overlap-badge ${city.label.class}`}>
|
||||||
|
{city.label.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tz-analog">
|
||||||
|
<svg viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="44" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="2" />
|
||||||
|
{[0, 3, 6, 9].map((n) => (
|
||||||
|
<line key={n}
|
||||||
|
x1="50" y1="10" x2="50" y2="16"
|
||||||
|
stroke="rgba(255,255,255,0.15)" strokeWidth="1.5"
|
||||||
|
transform={`rotate(${n * 30} 50 50)`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<line x1="50" y1="50" x2="50" y2="22"
|
||||||
|
stroke="url(#tzHand)" strokeWidth="2.5" strokeLinecap="round"
|
||||||
|
transform={`rotate(${angle} 50 50)`}
|
||||||
|
/>
|
||||||
|
<circle cx="50" cy="50" r="3" fill="#4ECDC4" />
|
||||||
|
</svg>
|
||||||
|
<span className="tz-digital">{localTime}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tz-overlap-bar">
|
||||||
|
<div className="tz-overlap-track">
|
||||||
|
<div
|
||||||
|
className={`tz-overlap-fill ${city.label.class}`}
|
||||||
|
style={{ width: `${(city.overlap / Math.max(homeEnd - homeStart, 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>重叠 {city.overlap}h / {homeEnd - homeStart}h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tz-status">
|
||||||
|
{city.isWorkTime ? "💼 当地工作时间" : "🌙 当地非工作时间"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="0" height="0">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="tzHand" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#FF6B6B" />
|
||||||
|
<stop offset="100%" stopColor="#4ECDC4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/components/Tools.tsx
Normal file
29
frontend/src/components/Tools.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { Tool } from "@/lib/types";
|
||||||
|
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
work: "💼", travel: "✈️", finance: "💳", connect: "🤝", health: "🏥", learn: "📚",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Tools({ tools }: { tools: Tool[] }) {
|
||||||
|
return (
|
||||||
|
<section className="section tools" id="tools">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🛠️ TOOLKIT</span>
|
||||||
|
<h2>游民必备工具箱</h2>
|
||||||
|
<p>精选远程工作与旅居生活的效率神器</p>
|
||||||
|
</div>
|
||||||
|
<div className="tools-grid">
|
||||||
|
{tools.map((t) => (
|
||||||
|
<div key={t.id} className="tool-card reveal" data-category={t.category}>
|
||||||
|
<span className="tool-emoji">{t.emoji || ICONS[t.category]}</span>
|
||||||
|
<h3>{t.name}</h3>
|
||||||
|
<p>{t.description}</p>
|
||||||
|
<div className="tool-tags">{t.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
frontend/src/components/TripPlanner.tsx
Normal file
156
frontend/src/components/TripPlanner.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import type { Destination, TripItem } from "@/lib/types";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "nomadflow-trip";
|
||||||
|
|
||||||
|
interface Props { destinations: Destination[] }
|
||||||
|
|
||||||
|
export default function TripPlanner({ destinations }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [trip, setTrip] = useState<TripItem[]>([]);
|
||||||
|
const [selected, setSelected] = useState("");
|
||||||
|
const [months, setMonths] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const shared = params.get("trip");
|
||||||
|
if (shared) {
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(atob(shared)) as TripItem[];
|
||||||
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
|
setTrip(decoded);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
|
||||||
|
toast("已导入分享的行程 🗺️");
|
||||||
|
window.history.replaceState({}, "", window.location.pathname + "#trip");
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) setTrip(JSON.parse(saved));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = (items: TripItem[]) => {
|
||||||
|
setTrip(items);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCity = () => {
|
||||||
|
const dest = destinations.find((d) => d.slug === selected);
|
||||||
|
if (!dest || trip.some((t) => t.slug === dest.slug)) return;
|
||||||
|
save([...trip, {
|
||||||
|
slug: dest.slug, name: dest.name, country: dest.country,
|
||||||
|
emoji: dest.emoji, cost: dest.cost, months,
|
||||||
|
}]);
|
||||||
|
setSelected("");
|
||||||
|
setMonths(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCity = (slug: string) => save(trip.filter((t) => t.slug !== slug));
|
||||||
|
const updateMonths = (slug: string, m: number) =>
|
||||||
|
save(trip.map((t) => t.slug === slug ? { ...t, months: m } : t));
|
||||||
|
|
||||||
|
const totalCost = trip.reduce((sum, t) => sum + t.cost * t.months, 0);
|
||||||
|
const totalMonths = trip.reduce((sum, t) => sum + t.months, 0);
|
||||||
|
|
||||||
|
const shareTrip = async () => {
|
||||||
|
const encoded = btoa(JSON.stringify(trip));
|
||||||
|
const url = `${window.location.origin}/?trip=${encoded}#trip`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast("分享链接已复制!发送给好友即可导入行程 🔗");
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportTrip = () => {
|
||||||
|
const text = trip.map((t, i) =>
|
||||||
|
`${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 (¥${(t.cost * t.months).toLocaleString()})`
|
||||||
|
).join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()}`;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast("行程已复制到剪贴板 📋");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section trip-section" id="trip">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🗓️ TRIP PLANNER</span>
|
||||||
|
<h2>行程规划器</h2>
|
||||||
|
<p>规划你的环球旅居路线,自动计算总预算</p>
|
||||||
|
</div>
|
||||||
|
<div className="trip-wrapper reveal">
|
||||||
|
<div className="trip-form">
|
||||||
|
<h3>➕ 添加城市</h3>
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>🏙️ 选择目的地</label>
|
||||||
|
<select value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||||
|
<option value="">请选择...</option>
|
||||||
|
{destinations.filter((d) => !trip.some((t) => t.slug === d.slug)).map((d) => (
|
||||||
|
<option key={d.slug} value={d.slug}>{d.emoji} {d.name}, {d.country}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>📅 停留月数: {months} 个月</label>
|
||||||
|
<input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={addCity} disabled={!selected}>
|
||||||
|
添加到行程 🚀
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="trip-timeline">
|
||||||
|
{trip.length === 0 ? (
|
||||||
|
<div className="trip-empty">
|
||||||
|
<span style={{ fontSize: "3rem" }}>🗺️</span>
|
||||||
|
<p>还没有添加城市,开始规划你的旅程吧</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="trip-route">
|
||||||
|
{trip.map((t, i) => (
|
||||||
|
<div key={t.slug} className="trip-stop">
|
||||||
|
<div className="trip-stop-dot">{t.emoji}</div>
|
||||||
|
{i < trip.length - 1 && <div className="trip-stop-line" />}
|
||||||
|
<div className="trip-stop-card">
|
||||||
|
<div className="trip-stop-header">
|
||||||
|
<strong>{t.name}, {t.country}</strong>
|
||||||
|
<button onClick={() => removeCity(t.slug)} className="trip-remove">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="trip-stop-controls">
|
||||||
|
<label>停留</label>
|
||||||
|
<input type="number" min={1} max={24} value={t.months}
|
||||||
|
onChange={(e) => updateMonths(t.slug, +e.target.value)} />
|
||||||
|
<span>个月</span>
|
||||||
|
<span className="trip-stop-cost">¥{(t.cost * t.months).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="trip-summary">
|
||||||
|
<div className="trip-summary-stat">
|
||||||
|
<span>🗓️ 总时长</span>
|
||||||
|
<strong>{totalMonths} 个月</strong>
|
||||||
|
</div>
|
||||||
|
<div className="trip-summary-stat">
|
||||||
|
<span>💰 总预算</span>
|
||||||
|
<strong className="gradient-text">¥{totalCost.toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="trip-summary-stat">
|
||||||
|
<span>🏙️ 城市数</span>
|
||||||
|
<strong>{trip.length} 个</strong>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" onClick={exportTrip}>📋 复制行程</button>
|
||||||
|
<button className="btn btn-ghost" onClick={shareTrip}>🔗 分享行程</button>
|
||||||
|
<button className="btn btn-ghost" onClick={() => save([])}>🗑️ 清空</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/src/components/VisaSection.tsx
Normal file
81
frontend/src/components/VisaSection.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { Visa } from "@/lib/types";
|
||||||
|
|
||||||
|
const FILTERS = [
|
||||||
|
{ key: "all", label: "🌏 全部" },
|
||||||
|
{ key: "easy", label: "✅ 简单" },
|
||||||
|
{ key: "medium", label: "📋 中等" },
|
||||||
|
{ key: "budget", label: "💰 低成本" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function VisaSection({ visas }: { visas: Visa[] }) {
|
||||||
|
const [filter, setFilter] = useState("all");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === "all") return visas;
|
||||||
|
if (filter === "easy") return visas.filter((v) => v.difficulty <= 35);
|
||||||
|
if (filter === "medium") return visas.filter((v) => v.difficulty > 35 && v.difficulty <= 55);
|
||||||
|
if (filter === "budget") return visas.filter((v) => v.badge_type === "budget");
|
||||||
|
return visas;
|
||||||
|
}, [visas, filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section visa-section" id="visa">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">📋 VISA GUIDE</span>
|
||||||
|
<h2>数字游民签证指南</h2>
|
||||||
|
<p>全球热门远程工作签证政策一览,助你合法旅居</p>
|
||||||
|
</div>
|
||||||
|
<div className="visa-filters reveal">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
className={`filter-btn${filter === f.key ? " active" : ""}`}
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="visa-grid">
|
||||||
|
{filtered.map((v, i) => (
|
||||||
|
<div key={v.id} className="visa-card reveal visible" style={{ animationDelay: `${i * 0.08}s` }}>
|
||||||
|
<div className="visa-flag">{v.flag}</div>
|
||||||
|
<h3>{v.name}</h3>
|
||||||
|
<div className={`visa-badge ${v.badge_type}`}>{v.badge}</div>
|
||||||
|
<ul className="visa-details">
|
||||||
|
<li>📅 有效期:{v.duration}</li>
|
||||||
|
<li>💰 收入要求:{v.income_req}</li>
|
||||||
|
<li>⏱️ 审批:{v.approval_time}</li>
|
||||||
|
<li>{v.extra}</li>
|
||||||
|
</ul>
|
||||||
|
<div className="visa-progress">
|
||||||
|
<span>难度</span>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${v.difficulty}%`,
|
||||||
|
background: v.difficulty <= 35
|
||||||
|
? "linear-gradient(90deg, #4ECDC4, #6ee7b7)"
|
||||||
|
: v.difficulty <= 55
|
||||||
|
? "linear-gradient(90deg, #FFE66D, #ffa500)"
|
||||||
|
: "linear-gradient(90deg, #FF6B6B, #ff8a8a)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="progress-label">{v.difficulty_label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="dest-empty reveal">暂无匹配的签证类型,试试其他筛选条件</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/components/WorldMap.tsx
Normal file
84
frontend/src/components/WorldMap.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props { destinations: Destination[] }
|
||||||
|
|
||||||
|
export default function WorldMap({ destinations }: Props) {
|
||||||
|
const [selected, setSelected] = useState<Destination | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section map-section" id="map">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">🗺️ WORLD MAP</span>
|
||||||
|
<h2>全球游民热力图</h2>
|
||||||
|
<p>点击地图上的标记,探索热门数字游民聚集地</p>
|
||||||
|
</div>
|
||||||
|
<div className="map-wrapper reveal">
|
||||||
|
<div className="map-svg-container">
|
||||||
|
<svg className="world-map" viewBox="0 0 1000 500">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="mapGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="#4ECDC4" stopOpacity="0.15" />
|
||||||
|
<stop offset="100%" stopColor="#4ECDC4" stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="pinGlow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1000" height="500" fill="url(#mapGlow)" rx="20" />
|
||||||
|
<g className="continents" opacity="0.25">
|
||||||
|
<path d="M180 120 Q220 100 280 110 Q320 130 310 170 Q290 200 250 210 Q200 220 170 190 Q150 160 180 120Z" fill="#4ECDC4" />
|
||||||
|
<path d="M420 100 Q480 90 530 110 Q560 140 550 180 Q520 210 470 200 Q430 180 420 140Z" fill="#4ECDC4" />
|
||||||
|
<path d="M530 130 Q600 120 660 140 Q700 170 690 210 Q650 240 590 230 Q540 210 530 170Z" fill="#4ECDC4" />
|
||||||
|
<path d="M700 160 Q780 150 830 180 Q860 220 840 260 Q790 280 730 260 Q690 230 700 190Z" fill="#4ECDC4" />
|
||||||
|
<path d="M200 260 Q250 250 290 280 Q310 320 280 360 Q240 380 200 360 Q170 330 180 290Z" fill="#4ECDC4" />
|
||||||
|
</g>
|
||||||
|
{destinations.map((d) => (
|
||||||
|
<g key={d.slug} className={`map-pin${selected?.slug === d.slug ? " active" : ""}`}
|
||||||
|
transform={`translate(${d.map_x}, ${d.map_y})`}
|
||||||
|
onClick={() => setSelected(d)} style={{ cursor: "pointer" }}>
|
||||||
|
<circle r="20" fill="rgba(255,107,107,0.15)" className="pin-pulse" />
|
||||||
|
<circle r="8" fill="#FF6B6B" filter="url(#pinGlow)" />
|
||||||
|
<text y="-16" textAnchor="middle" fontSize="14">{d.emoji}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="map-info-panel">
|
||||||
|
{selected ? (
|
||||||
|
<div className="map-info-city">
|
||||||
|
<div className="city-emoji">{selected.emoji}</div>
|
||||||
|
<h3>{selected.name}, {selected.country}</h3>
|
||||||
|
<span className="modal-tag">{selected.tag}</span>
|
||||||
|
<p className="city-desc">{selected.description}</p>
|
||||||
|
<div className="map-info-stats">
|
||||||
|
<div className="map-stat"><strong>¥{selected.cost.toLocaleString()}</strong>月生活费</div>
|
||||||
|
<div className="map-stat"><strong>{selected.speed}Mbps</strong>网速</div>
|
||||||
|
<div className="map-stat"><strong>{selected.temperature}°C</strong>均温</div>
|
||||||
|
<div className="map-stat"><strong>⭐ {selected.rating}</strong>评分</div>
|
||||||
|
</div>
|
||||||
|
<div className="map-info-actions">
|
||||||
|
<Link href={`/destinations/${selected.slug}`} className="btn btn-primary btn-sm">
|
||||||
|
查看详情 →
|
||||||
|
</Link>
|
||||||
|
<Link href={`/#trip`} className="btn btn-ghost btn-sm">🗓️ 加入行程</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="map-info-default">
|
||||||
|
<span className="map-info-icon">👆</span>
|
||||||
|
<h3>选择一个城市</h3>
|
||||||
|
<p>点击地图上的标记查看详细信息</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
frontend/src/lib/api.ts
Normal file
93
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import type {
|
||||||
|
AuthResponse, BlogPost, BlogPostDetail, ChartData, CostResult, Destination, FAQ,
|
||||||
|
ProfileStats, SearchResult, Stats, Testimonial, Tool, Visa,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
||||||
|
|
||||||
|
// Fallback data when API is unavailable (build time / offline)
|
||||||
|
const FALLBACK_STATS: Stats = { countries: 195, avg_cost: 42, satisfaction: 87, total_nomads: "35.6M", active_today: 127 };
|
||||||
|
const FALLBACK_TICKER = { messages: ["🌴 小林 刚刚抵达清迈", "💻 Marco 在里斯本完成了 Sprint", "✈️ 今日新增 127 位游民出发"] };
|
||||||
|
|
||||||
|
async function fetchAPI<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const isMutation = options?.method && options.method !== "GET";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||||
|
...(isMutation ? { cache: "no-store" } : { next: { revalidate: 60 } }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
} catch (err) {
|
||||||
|
if (isMutation) throw err;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithFallback<T>(path: string, fallback: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fetchAPI<T>(path);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getStats: () => fetchWithFallback("/stats", FALLBACK_STATS),
|
||||||
|
getTicker: () => fetchWithFallback("/ticker", FALLBACK_TICKER),
|
||||||
|
getDestinations: (params?: { region?: string; search?: string; sort?: string }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.region) q.set("region", params.region);
|
||||||
|
if (params?.search) q.set("search", params.search);
|
||||||
|
if (params?.sort) q.set("sort", params.sort);
|
||||||
|
const qs = q.toString();
|
||||||
|
return fetchAPI<Destination[]>(`/destinations${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
|
getDestination: (slug: string) => fetchAPI<Destination>(`/destinations/${slug}`),
|
||||||
|
compareDestinations: (slugs: string[]) =>
|
||||||
|
fetchAPI<Destination[]>("/destinations/compare", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ slugs }),
|
||||||
|
}),
|
||||||
|
getVisas: () => fetchWithFallback<Visa[]>("/visas", []),
|
||||||
|
getFaqs: () => fetchWithFallback<FAQ[]>("/faqs", []),
|
||||||
|
getTestimonials: () => fetchWithFallback<Testimonial[]>("/testimonials", []),
|
||||||
|
getTools: () => fetchWithFallback<Tool[]>("/tools", []),
|
||||||
|
getBlog: () => fetchWithFallback<BlogPost[]>("/blog", []),
|
||||||
|
getBlogPost: (slug: string) => fetchAPI<BlogPostDetail>(`/blog/${slug}`),
|
||||||
|
subscribe: (email: string) =>
|
||||||
|
fetchAPI<{ success: boolean; message: string }>("/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
calculateCost: (data: { destination_slug: string; housing: string; months: number }) =>
|
||||||
|
fetchAPI<CostResult>("/calculator", { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
getChartCost: () => fetchWithFallback<ChartData>("/charts/cost", { labels: [], datasets: [{ data: [] }] }),
|
||||||
|
getChartSpeed: () => fetchWithFallback<ChartData>("/charts/speed", { labels: [], datasets: [{ data: [] }] }),
|
||||||
|
getChartGrowth: () => fetchWithFallback<ChartData>("/charts/growth", { labels: [], datasets: [{ data: [] }] }),
|
||||||
|
getChartRadar: () => fetchWithFallback<ChartData>("/charts/radar", { labels: [], datasets: [] }),
|
||||||
|
// Auth
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
fetchAPI<AuthResponse>("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
|
||||||
|
register: (email: string, password: string, name: string) =>
|
||||||
|
fetchAPI<AuthResponse>("/auth/register", { method: "POST", body: JSON.stringify({ email, password, name }) }),
|
||||||
|
demoLogin: () => fetchAPI<AuthResponse>("/auth/demo"),
|
||||||
|
getFavorites: (token: string) =>
|
||||||
|
fetchAPI<{ slugs: string[] }>("/auth/favorites", { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
toggleFavorite: (slug: string, token: string) =>
|
||||||
|
fetchAPI<{ slugs: string[] }>("/auth/favorites", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ destination_slug: slug }),
|
||||||
|
}),
|
||||||
|
getFavoriteDestinations: (token: string) =>
|
||||||
|
fetchAPI<Destination[]>("/auth/favorites/detail", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
getProfileStats: (token: string) =>
|
||||||
|
fetchAPI<ProfileStats>("/auth/profile/stats", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
search: (q: string) => fetchAPI<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
|
||||||
|
};
|
||||||
105
frontend/src/lib/auth.tsx
Normal file
105
frontend/src/lib/auth.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { UserProfile } from "@/lib/types";
|
||||||
|
|
||||||
|
interface AuthCtx {
|
||||||
|
user: UserProfile | null;
|
||||||
|
token: string | null;
|
||||||
|
favorites: string[];
|
||||||
|
login: (email: string, password: string) => Promise<boolean>;
|
||||||
|
register: (email: string, password: string, name: string) => Promise<boolean>;
|
||||||
|
demoLogin: () => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
toggleFavorite: (slug: string) => Promise<void>;
|
||||||
|
isFavorite: (slug: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthCtx | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [favorites, setFavorites] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const loadFavorites = useCallback(async (t: string) => {
|
||||||
|
try {
|
||||||
|
const data = await api.getFavorites(t);
|
||||||
|
setFavorites(data.slugs);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("nomad-token");
|
||||||
|
const savedUser = localStorage.getItem("nomad-user");
|
||||||
|
if (saved && savedUser) {
|
||||||
|
setToken(saved);
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
loadFavorites(saved);
|
||||||
|
}
|
||||||
|
}, [loadFavorites]);
|
||||||
|
|
||||||
|
const persist = (t: string, u: UserProfile) => {
|
||||||
|
setToken(t);
|
||||||
|
setUser(u);
|
||||||
|
localStorage.setItem("nomad-token", t);
|
||||||
|
localStorage.setItem("nomad-user", JSON.stringify(u));
|
||||||
|
loadFavorites(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const data = await api.login(email, password);
|
||||||
|
persist(data.token, data.user);
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (email: string, password: string, name: string) => {
|
||||||
|
try {
|
||||||
|
const data = await api.register(email, password, name);
|
||||||
|
persist(data.token, data.user);
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const demoLogin = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.demoLogin();
|
||||||
|
persist(data.token, data.user);
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
setFavorites([]);
|
||||||
|
localStorage.removeItem("nomad-token");
|
||||||
|
localStorage.removeItem("nomad-user");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavorite = async (slug: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const data = await api.toggleFavorite(slug, token);
|
||||||
|
setFavorites(data.slugs);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{
|
||||||
|
user, token, favorites, login, register, demoLogin, logout,
|
||||||
|
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
52
frontend/src/lib/toast.tsx
Normal file
52
frontend/src/lib/toast.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type ToastType = "success" | "error" | "info";
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toast: (message: string, type?: ToastType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<ToastType, string> = {
|
||||||
|
success: "✅",
|
||||||
|
error: "❌",
|
||||||
|
info: "💡",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const toast = useCallback((message: string, type: ToastType = "success") => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setItems((prev) => [...prev, { id, message, type, icon: ICONS[type] }]);
|
||||||
|
setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), 3800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast }}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-stack" aria-live="polite">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div key={t.id} className={`toast toast-${t.type} show`}>
|
||||||
|
<span className="toast-icon">{t.icon}</span>
|
||||||
|
<span className="toast-msg">{t.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext);
|
||||||
|
}
|
||||||
133
frontend/src/lib/types.ts
Normal file
133
frontend/src/lib/types.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
export interface Destination {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
emoji: string;
|
||||||
|
tag: string;
|
||||||
|
description: string;
|
||||||
|
region: string;
|
||||||
|
cost: number;
|
||||||
|
speed: number;
|
||||||
|
temperature: number;
|
||||||
|
rating: number;
|
||||||
|
hue: number;
|
||||||
|
nomads_count: string;
|
||||||
|
highlights: string[];
|
||||||
|
map_x: number;
|
||||||
|
map_y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Visa {
|
||||||
|
id: string;
|
||||||
|
country: string;
|
||||||
|
flag: string;
|
||||||
|
name: string;
|
||||||
|
badge: string;
|
||||||
|
badge_type: string;
|
||||||
|
duration: string;
|
||||||
|
income_req: string;
|
||||||
|
approval_time: string;
|
||||||
|
extra: string;
|
||||||
|
difficulty: number;
|
||||||
|
difficulty_label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FAQ {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Testimonial {
|
||||||
|
id: string;
|
||||||
|
avatar: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
role: string;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tool {
|
||||||
|
id: string;
|
||||||
|
emoji: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogPost {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
emoji: string;
|
||||||
|
author: string;
|
||||||
|
published_at: string;
|
||||||
|
read_time: number;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogPostDetail extends BlogPost {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
user: UserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileStats {
|
||||||
|
favorites_count: number;
|
||||||
|
destinations_explored: number;
|
||||||
|
member_since: string;
|
||||||
|
nomad_level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
countries: number;
|
||||||
|
avg_cost: number;
|
||||||
|
satisfaction: number;
|
||||||
|
total_nomads: string;
|
||||||
|
active_today: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostResult {
|
||||||
|
destination: string;
|
||||||
|
emoji: string;
|
||||||
|
months: number;
|
||||||
|
breakdown: Record<string, number>;
|
||||||
|
total: number;
|
||||||
|
per_month: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
emoji: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripItem {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
emoji: string;
|
||||||
|
cost: number;
|
||||||
|
months: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: { label?: string; data: number[] }[];
|
||||||
|
}
|
||||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
7
frontend/vercel.json
Normal file
7
frontend/vercel.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"buildCommand": "npm run build",
|
||||||
|
"devCommand": "npm run dev",
|
||||||
|
"installCommand": "npm install",
|
||||||
|
"framework": "nextjs",
|
||||||
|
"regions": ["hkg1", "sin1"]
|
||||||
|
}
|
||||||
908
index.html
Normal file
908
index.html
Normal file
@ -0,0 +1,908 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NomadFlow · 数字游民旅居指南</title>
|
||||||
|
<meta name="description" content="探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 加载动画 -->
|
||||||
|
<div class="loader" id="loader">
|
||||||
|
<div class="loader-inner">
|
||||||
|
<svg class="loader-globe" viewBox="0 0 80 80" fill="none">
|
||||||
|
<circle cx="40" cy="40" r="35" stroke="url(#loaderGrad)" stroke-width="3" stroke-dasharray="60 160" class="loader-ring"/>
|
||||||
|
<text x="40" y="48" text-anchor="middle" font-size="24">🌍</text>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="loaderGrad" x1="0" y1="0" x2="80" y2="80">
|
||||||
|
<stop offset="0%" stop-color="#FF6B6B"/>
|
||||||
|
<stop offset="100%" stop-color="#4ECDC4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<p class="loader-text">NomadFlow</p>
|
||||||
|
<div class="loader-bar"><div class="loader-progress"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 粒子背景 -->
|
||||||
|
<canvas id="particle-canvas" aria-hidden="true"></canvas>
|
||||||
|
|
||||||
|
<!-- 导航 -->
|
||||||
|
<header class="navbar" id="navbar">
|
||||||
|
<div class="nav-container">
|
||||||
|
<a href="#" class="logo">
|
||||||
|
<svg class="logo-icon" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="20" cy="20" r="18" stroke="url(#logoGrad)" stroke-width="2.5"/>
|
||||||
|
<path d="M12 22c4-8 12-8 16 0" stroke="url(#logoGrad)" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="28" cy="14" r="3" fill="url(#logoGrad)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="logoGrad" x1="0" y1="0" x2="40" y2="40">
|
||||||
|
<stop offset="0%" stop-color="#FF6B6B"/>
|
||||||
|
<stop offset="50%" stop-color="#FFE66D"/>
|
||||||
|
<stop offset="100%" stop-color="#4ECDC4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<span>NomadFlow</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="#map" data-section="map">🗺️ 地图</a>
|
||||||
|
<a href="#destinations" data-section="destinations">🌍 目的地</a>
|
||||||
|
<a href="#visa" data-section="visa">📋 签证</a>
|
||||||
|
<a href="#data" data-section="data">📊 数据</a>
|
||||||
|
<a href="#faq" data-section="faq">❓ FAQ</a>
|
||||||
|
<a href="#community" data-section="community">🤝 社区</a>
|
||||||
|
</nav>
|
||||||
|
<button class="nav-cta" id="theme-toggle" aria-label="切换主题">
|
||||||
|
<span class="theme-icon">🌙</span>
|
||||||
|
</button>
|
||||||
|
<button class="mobile-menu-btn" id="mobile-menu-btn" aria-label="菜单">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 实时滚动条 -->
|
||||||
|
<div class="ticker-bar">
|
||||||
|
<div class="ticker-track">
|
||||||
|
<span>🌴 小林 刚刚抵达清迈</span>
|
||||||
|
<span>💻 Marco 在里斯本完成了 Sprint</span>
|
||||||
|
<span>✈️ 今日新增 127 位游民出发</span>
|
||||||
|
<span>🏝️ 巴厘岛 Co-working 今日 89% 满座</span>
|
||||||
|
<span>📶 清迈平均网速 95Mbps</span>
|
||||||
|
<span>🌅 东京远程工作者满意度 9.1</span>
|
||||||
|
<span>🌴 小林 刚刚抵达清迈</span>
|
||||||
|
<span>💻 Marco 在里斯本完成了 Sprint</span>
|
||||||
|
<span>✈️ 今日新增 127 位游民出发</span>
|
||||||
|
<span>🏝️ 巴厘岛 Co-working 今日 89% 满座</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="hero" id="hero">
|
||||||
|
<div class="hero-bg-shapes">
|
||||||
|
<div class="shape shape-1"></div>
|
||||||
|
<div class="shape shape-2"></div>
|
||||||
|
<div class="shape shape-3"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-badge reveal">
|
||||||
|
<span class="badge-dot"></span>
|
||||||
|
🚀 全球 35M+ 数字游民正在路上
|
||||||
|
</div>
|
||||||
|
<h1 class="hero-title reveal">
|
||||||
|
用一行代码<br/>
|
||||||
|
<span class="gradient-text">环游世界</span> 🌏
|
||||||
|
</h1>
|
||||||
|
<p class="hero-subtitle reveal">
|
||||||
|
<span id="typewriter"></span><span class="typewriter-cursor">|</span>
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions reveal">
|
||||||
|
<a href="#destinations" class="btn btn-primary">
|
||||||
|
<span>探索目的地</span>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
<a href="#data" class="btn btn-ghost">
|
||||||
|
<span>📈 查看数据</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats reveal">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" data-target="195">0</span>
|
||||||
|
<span class="stat-label">🗺️ 可探索国家</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" data-target="42">0</span>
|
||||||
|
<span class="stat-label">💰 平均月生活费 (千元)</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" data-target="87">0</span>
|
||||||
|
<span class="stat-label">😊 生活满意度 %</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-visual reveal">
|
||||||
|
<div class="globe-container">
|
||||||
|
<svg class="globe-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="globeGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#4ECDC4" stop-opacity="0.3"/>
|
||||||
|
<stop offset="100%" stop-color="#4ECDC4" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="orbitGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FF6B6B"/>
|
||||||
|
<stop offset="100%" stop-color="#4ECDC4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="200" cy="200" r="160" fill="url(#globeGlow)"/>
|
||||||
|
<circle cx="200" cy="200" r="120" fill="none" stroke="rgba(78,205,196,0.2)" stroke-width="1"/>
|
||||||
|
<circle cx="200" cy="200" r="90" fill="none" stroke="rgba(78,205,196,0.15)" stroke-width="1"/>
|
||||||
|
<ellipse cx="200" cy="200" rx="120" ry="40" fill="none" stroke="rgba(78,205,196,0.3)" stroke-width="1.5" class="orbit-ring"/>
|
||||||
|
<ellipse cx="200" cy="200" rx="40" ry="120" fill="none" stroke="rgba(255,107,107,0.3)" stroke-width="1.5" class="orbit-ring orbit-ring-2"/>
|
||||||
|
<!-- 大陆轮廓简化 -->
|
||||||
|
<path d="M160 140 Q180 130 200 135 Q220 128 240 140 Q250 155 245 170 Q235 185 220 190 Q200 195 185 188 Q165 180 160 165 Z" fill="rgba(78,205,196,0.4)" class="continent"/>
|
||||||
|
<path d="M130 210 Q145 200 160 215 Q155 235 140 240 Q125 235 130 210 Z" fill="rgba(78,205,196,0.3)" class="continent"/>
|
||||||
|
<path d="M250 200 Q270 195 280 210 Q275 230 260 235 Q245 228 250 200 Z" fill="rgba(78,205,196,0.35)" class="continent"/>
|
||||||
|
<!-- 游标点 -->
|
||||||
|
<g class="nomad-dots">
|
||||||
|
<circle cx="180" cy="155" r="5" fill="#FF6B6B" class="pulse-dot"/>
|
||||||
|
<circle cx="260" cy="215" r="5" fill="#FFE66D" class="pulse-dot"/>
|
||||||
|
<circle cx="145" cy="225" r="5" fill="#4ECDC4" class="pulse-dot"/>
|
||||||
|
<circle cx="220" cy="175" r="5" fill="#A78BFA" class="pulse-dot"/>
|
||||||
|
</g>
|
||||||
|
<!-- 飞行轨迹 -->
|
||||||
|
<path d="M180 155 Q220 140 260 215" fill="none" stroke="url(#orbitGrad)" stroke-width="2" stroke-dasharray="6 4" class="flight-path"/>
|
||||||
|
</svg>
|
||||||
|
<div class="floating-cards">
|
||||||
|
<div class="float-card fc-1">🏝️ 巴厘岛<br/><small>WiFi 98Mbps</small></div>
|
||||||
|
<div class="float-card fc-2">☕ 里斯本<br/><small>€800/月</small></div>
|
||||||
|
<div class="float-card fc-3">🏔️ 清迈<br/><small>⭐ 4.9</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scroll-indicator">
|
||||||
|
<span>向下滚动</span>
|
||||||
|
<div class="scroll-arrow">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 交互地图 -->
|
||||||
|
<section class="section map-section" id="map">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<span class="section-tag">🗺️ WORLD MAP</span>
|
||||||
|
<h2>全球游民热力图</h2>
|
||||||
|
<p>点击地图上的标记,探索热门数字游民聚集地</p>
|
||||||
|
</div>
|
||||||
|
<div class="map-wrapper reveal">
|
||||||
|
<div class="map-svg-container">
|
||||||
|
<svg class="world-map" viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="mapGlow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#4ECDC4" stop-opacity="0.15"/>
|
||||||
|
<stop offset="100%" stop-color="#4ECDC4" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="pinGlow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1000" height="500" fill="url(#mapGlow)" rx="20"/>
|
||||||
|
<!-- 简化大陆 -->
|
||||||
|
<g class="continents" opacity="0.25">
|
||||||
|
<path d="M180 120 Q220 100 280 110 Q320 130 310 170 Q290 200 250 210 Q200 220 170 190 Q150 160 180 120Z" fill="#4ECDC4"/>
|
||||||
|
<path d="M420 100 Q480 90 530 110 Q560 140 550 180 Q520 210 470 200 Q430 180 420 140Z" fill="#4ECDC4"/>
|
||||||
|
<path d="M530 130 Q600 120 660 140 Q700 170 690 210 Q650 240 590 230 Q540 210 530 170Z" fill="#4ECDC4"/>
|
||||||
|
<path d="M700 160 Q780 150 830 180 Q860 220 840 260 Q790 280 730 260 Q690 230 700 190Z" fill="#4ECDC4"/>
|
||||||
|
<path d="M780 280 Q830 270 870 300 Q890 340 860 370 Q820 390 780 370 Q750 340 760 310Z" fill="#4ECDC4"/>
|
||||||
|
<path d="M200 260 Q250 250 290 280 Q310 320 280 360 Q240 380 200 360 Q170 330 180 290Z" fill="#4ECDC4"/>
|
||||||
|
<path d="M300 300 Q360 290 400 320 Q420 360 390 400 Q340 420 300 390 Q270 360 280 330Z" fill="#4ECDC4"/>
|
||||||
|
</g>
|
||||||
|
<!-- 网格线 -->
|
||||||
|
<g class="map-grid" opacity="0.08">
|
||||||
|
<line x1="0" y1="125" x2="1000" y2="125" stroke="#4ECDC4"/>
|
||||||
|
<line x1="0" y1="250" x2="1000" y2="250" stroke="#4ECDC4"/>
|
||||||
|
<line x1="0" y1="375" x2="1000" y2="375" stroke="#4ECDC4"/>
|
||||||
|
<line x1="250" y1="0" x2="250" y2="500" stroke="#4ECDC4"/>
|
||||||
|
<line x1="500" y1="0" x2="500" y2="500" stroke="#4ECDC4"/>
|
||||||
|
<line x1="750" y1="0" x2="750" y2="500" stroke="#4ECDC4"/>
|
||||||
|
</g>
|
||||||
|
<!-- 交互标记 -->
|
||||||
|
<g class="map-pin" data-city="bali" transform="translate(720, 310)">
|
||||||
|
<circle r="20" fill="rgba(255,107,107,0.15)" class="pin-pulse"/>
|
||||||
|
<circle r="8" fill="#FF6B6B" filter="url(#pinGlow)"/>
|
||||||
|
<text y="-16" text-anchor="middle" font-size="14">🏝️</text>
|
||||||
|
</g>
|
||||||
|
<g class="map-pin" data-city="lisbon" transform="translate(430, 195)">
|
||||||
|
<circle r="20" fill="rgba(78,205,196,0.15)" class="pin-pulse"/>
|
||||||
|
<circle r="8" fill="#4ECDC4" filter="url(#pinGlow)"/>
|
||||||
|
<text y="-16" text-anchor="middle" font-size="14">🌊</text>
|
||||||
|
</g>
|
||||||
|
<g class="map-pin" data-city="chiangmai" transform="translate(700, 240)">
|
||||||
|
<circle r="20" fill="rgba(255,230,109,0.15)" class="pin-pulse"/>
|
||||||
|
<circle r="8" fill="#FFE66D" filter="url(#pinGlow)"/>
|
||||||
|
<text y="-16" text-anchor="middle" font-size="14">🏔️</text>
|
||||||
|
</g>
|
||||||
|
<g class="map-pin" data-city="mexico" transform="translate(220, 240)">
|
||||||
|
<circle r="20" fill="rgba(167,139,250,0.15)" class="pin-pulse"/>
|
||||||
|
<circle r="8" fill="#A78BFA" filter="url(#pinGlow)"/>
|
||||||
|
<text y="-16" text-anchor="middle" font-size="14">🌃</text>
|
||||||
|
</g>
|
||||||
|
<g class="map-pin" data-city="barcelona" transform="translate(460, 200)">
|
||||||
|
<circle r="20" fill="rgba(244,114,182,0.15)" class="pin-pulse"/>
|
||||||
|
<circle r="8" fill="#F472B6" filter="url(#pinGlow)"/>
|
||||||
|
<text y="-16" text-anchor="middle" font-size="14">🏖️</text>
|
||||||
|
</g>
|
||||||
|
<g class="map-pin" data-city="tokyo" transform="translate(820, 210)">
|
||||||
|
<circle r="20" fill="rgba(96,165,250,0.15)" class="pin-pulse"/>
|
||||||
|
<circle r="8" fill="#60A5FA" filter="url(#pinGlow)"/>
|
||||||
|
<text y="-16" text-anchor="middle" font-size="14">🗼</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="map-info-panel" id="map-info-panel">
|
||||||
|
<div class="map-info-default">
|
||||||
|
<span class="map-info-icon">👆</span>
|
||||||
|
<h3>选择一个城市</h3>
|
||||||
|
<p>点击地图上的标记查看详细信息</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 目的地 -->
|
||||||
|
<section class="section destinations" id="destinations">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<span class="section-tag">🌴 TOP DESTINATIONS</span>
|
||||||
|
<h2>热门旅居目的地</h2>
|
||||||
|
<p>精选全球最适合远程工作的城市,每一站都是一段新的故事</p>
|
||||||
|
</div>
|
||||||
|
<div class="dest-filters reveal">
|
||||||
|
<div class="filter-search">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<input type="text" id="dest-search" placeholder="搜索城市..." />
|
||||||
|
</div>
|
||||||
|
<div class="filter-tags">
|
||||||
|
<button class="filter-btn active" data-filter="all">🌏 全部</button>
|
||||||
|
<button class="filter-btn" data-filter="sea">🌴 东南亚</button>
|
||||||
|
<button class="filter-btn" data-filter="europe">🏰 欧洲</button>
|
||||||
|
<button class="filter-btn" data-filter="latam">🌮 拉美</button>
|
||||||
|
<button class="filter-btn" data-filter="asia">🏯 东亚</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-sort">
|
||||||
|
<select id="dest-sort">
|
||||||
|
<option value="rating">⭐ 评分排序</option>
|
||||||
|
<option value="cost-asc">💰 价格从低到高</option>
|
||||||
|
<option value="cost-desc">💰 价格从高到低</option>
|
||||||
|
<option value="speed">📶 网速排序</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dest-grid" id="dest-grid">
|
||||||
|
<article class="dest-card reveal" data-tilt data-id="bali" data-region="sea" data-cost="4500" data-speed="85" data-rating="9.2">
|
||||||
|
<div class="dest-img" style="--hue: 170">
|
||||||
|
<div class="dest-emoji">🏝️</div>
|
||||||
|
<div class="dest-overlay">
|
||||||
|
<span class="dest-tag">东南亚 · 热带天堂</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dest-body">
|
||||||
|
<h3>巴厘岛, 印尼</h3>
|
||||||
|
<p>乌布的数字游民社区闻名全球,低成本高品质生活的典范</p>
|
||||||
|
<div class="dest-meta">
|
||||||
|
<span>💰 ¥4,500/月</span>
|
||||||
|
<span>📶 85Mbps</span>
|
||||||
|
<span>🌡️ 28°C</span>
|
||||||
|
</div>
|
||||||
|
<div class="dest-rating">
|
||||||
|
<span class="stars">⭐⭐⭐⭐⭐</span>
|
||||||
|
<span>9.2 分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dest-card reveal" data-tilt data-id="lisbon" data-region="europe" data-cost="9000" data-speed="120" data-rating="9.5">
|
||||||
|
<div class="dest-img" style="--hue: 220">
|
||||||
|
<div class="dest-emoji">🌊</div>
|
||||||
|
<div class="dest-overlay">
|
||||||
|
<span class="dest-tag">欧洲 · 海滨明珠</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dest-body">
|
||||||
|
<h3>里斯本, 葡萄牙</h3>
|
||||||
|
<p>D7签证友好,阳光海岸与悠久历史的完美融合</p>
|
||||||
|
<div class="dest-meta">
|
||||||
|
<span>💰 ¥9,000/月</span>
|
||||||
|
<span>📶 120Mbps</span>
|
||||||
|
<span>🌡️ 22°C</span>
|
||||||
|
</div>
|
||||||
|
<div class="dest-rating">
|
||||||
|
<span class="stars">⭐⭐⭐⭐⭐</span>
|
||||||
|
<span>9.5 分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dest-card reveal" data-tilt data-id="chiangmai" data-region="sea" data-cost="3800" data-speed="95" data-rating="9.4">
|
||||||
|
<div class="dest-img" style="--hue: 45">
|
||||||
|
<div class="dest-emoji">🏔️</div>
|
||||||
|
<div class="dest-overlay">
|
||||||
|
<span class="dest-tag">东南亚 · 文化古城</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dest-body">
|
||||||
|
<h3>清迈, 泰国</h3>
|
||||||
|
<p>数字游民大本营,咖啡文化与夜市生活的天堂</p>
|
||||||
|
<div class="dest-meta">
|
||||||
|
<span>💰 ¥3,800/月</span>
|
||||||
|
<span>📶 95Mbps</span>
|
||||||
|
<span>🌡️ 30°C</span>
|
||||||
|
</div>
|
||||||
|
<div class="dest-rating">
|
||||||
|
<span class="stars">⭐⭐⭐⭐⭐</span>
|
||||||
|
<span>9.4 分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dest-card reveal" data-tilt data-id="mexico" data-region="latam" data-cost="6500" data-speed="75" data-rating="8.8">
|
||||||
|
<div class="dest-img" style="--hue: 300">
|
||||||
|
<div class="dest-emoji">🌃</div>
|
||||||
|
<div class="dest-overlay">
|
||||||
|
<span class="dest-tag">拉美 · 活力之都</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dest-body">
|
||||||
|
<h3>墨西哥城, 墨西哥</h3>
|
||||||
|
<p>艺术、美食与科技交织,时区便利对接北美市场</p>
|
||||||
|
<div class="dest-meta">
|
||||||
|
<span>💰 ¥6,500/月</span>
|
||||||
|
<span>📶 75Mbps</span>
|
||||||
|
<span>🌡️ 18°C</span>
|
||||||
|
</div>
|
||||||
|
<div class="dest-rating">
|
||||||
|
<span class="stars">⭐⭐⭐⭐</span>
|
||||||
|
<span>8.8 分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dest-card reveal" data-tilt data-id="barcelona" data-region="europe" data-cost="10500" data-speed="150" data-rating="9.1">
|
||||||
|
<div class="dest-img" style="--hue: 130">
|
||||||
|
<div class="dest-emoji">🏖️</div>
|
||||||
|
<div class="dest-overlay">
|
||||||
|
<span class="dest-tag">欧洲 · 地中海</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dest-body">
|
||||||
|
<h3>巴塞罗那, 西班牙</h3>
|
||||||
|
<p>高迪建筑与创业生态并存,Nomad Visa 政策领先</p>
|
||||||
|
<div class="dest-meta">
|
||||||
|
<span>💰 ¥10,500/月</span>
|
||||||
|
<span>📶 150Mbps</span>
|
||||||
|
<span>🌡️ 20°C</span>
|
||||||
|
</div>
|
||||||
|
<div class="dest-rating">
|
||||||
|
<span class="stars">⭐⭐⭐⭐⭐</span>
|
||||||
|
<span>9.1 分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dest-card reveal" data-tilt data-id="tokyo" data-region="asia" data-cost="12000" data-speed="200" data-rating="8.6">
|
||||||
|
<div class="dest-img" style="--hue: 10">
|
||||||
|
<div class="dest-emoji">🗼</div>
|
||||||
|
<div class="dest-overlay">
|
||||||
|
<span class="dest-tag">亚洲 · 现代都市</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dest-body">
|
||||||
|
<h3>东京, 日本</h3>
|
||||||
|
<p>极致效率与安全,适合追求高品质生活的远程工作者</p>
|
||||||
|
<div class="dest-meta">
|
||||||
|
<span>💰 ¥12,000/月</span>
|
||||||
|
<span>📶 200Mbps</span>
|
||||||
|
<span>🌡️ 15°C</span>
|
||||||
|
</div>
|
||||||
|
<div class="dest-rating">
|
||||||
|
<span class="stars">⭐⭐⭐⭐</span>
|
||||||
|
<span>8.6 分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p class="dest-empty" id="dest-empty" hidden>😢 没有找到匹配的目的地,试试其他筛选条件</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 生活方式 -->
|
||||||
|
<section class="section lifestyle" id="lifestyle">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<span class="section-tag">💻 NOMAD LIFE</span>
|
||||||
|
<h2>数字游民的一天</h2>
|
||||||
|
<p>自由不等于散漫,高效工作才能尽情探索</p>
|
||||||
|
</div>
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="timeline-line">
|
||||||
|
<svg viewBox="0 0 4 600" preserveAspectRatio="none">
|
||||||
|
<line x1="2" y1="0" x2="2" y2="600" stroke="url(#timelineGrad)" stroke-width="3" stroke-dasharray="8 6" class="timeline-svg-line"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="timelineGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FF6B6B"/>
|
||||||
|
<stop offset="50%" stop-color="#FFE66D"/>
|
||||||
|
<stop offset="100%" stop-color="#4ECDC4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item reveal">
|
||||||
|
<div class="timeline-time">🌅 07:00</div>
|
||||||
|
<div class="timeline-card">
|
||||||
|
<div class="timeline-icon">🧘</div>
|
||||||
|
<h3>晨间仪式</h3>
|
||||||
|
<p>瑜伽、冥想或海边跑步,用身心唤醒开启新的一天</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item reveal">
|
||||||
|
<div class="timeline-time">☕ 09:00</div>
|
||||||
|
<div class="timeline-card">
|
||||||
|
<div class="timeline-icon">💻</div>
|
||||||
|
<h3>深度工作</h3>
|
||||||
|
<p>在 Co-working Space 或咖啡馆专注 4 小时,完成核心任务</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item reveal">
|
||||||
|
<div class="timeline-time">🍜 13:00</div>
|
||||||
|
<div class="timeline-card">
|
||||||
|
<div class="timeline-icon">🍽️</div>
|
||||||
|
<h3>本地探索</h3>
|
||||||
|
<p>品尝地道美食,与当地人交流,感受文化碰撞</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item reveal">
|
||||||
|
<div class="timeline-time">🌇 17:00</div>
|
||||||
|
<div class="timeline-card">
|
||||||
|
<div class="timeline-icon">📞</div>
|
||||||
|
<h3>跨时区协作</h3>
|
||||||
|
<p>与全球团队视频会议,灵活安排跨时区沟通</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item reveal">
|
||||||
|
<div class="timeline-time">🌙 20:00</div>
|
||||||
|
<div class="timeline-card">
|
||||||
|
<div class="timeline-icon">🎉</div>
|
||||||
|
<h3>社群社交</h3>
|
||||||
|
<p>参加 Nomad Meetup,结识来自世界各地的同行者</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 数据洞察 -->
|
||||||
|
<section class="section data-section" id="data">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<span class="section-tag">📊 DATA INSIGHTS</span>
|
||||||
|
<h2>旅居数据洞察</h2>
|
||||||
|
<p>用数据帮你做出更明智的目的地选择</p>
|
||||||
|
</div>
|
||||||
|
<div class="charts-grid">
|
||||||
|
<div class="chart-card reveal">
|
||||||
|
<h3>💰 月度生活成本对比</h3>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="costChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card reveal">
|
||||||
|
<h3>📶 网络速度排行</h3>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="speedChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card chart-wide reveal">
|
||||||
|
<h3>📈 数字游民增长趋势 (2019-2026)</h3>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="growthChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card reveal">
|
||||||
|
<h3>🎯 选择因素权重</h3>
|
||||||
|
<div class="chart-wrap chart-radar">
|
||||||
|
<canvas id="radarChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 签证指南 -->
|
||||||
|
<section class="section visa-section" id="visa">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<span class="section-tag">📋 VISA GUIDE</span>
|
||||||
|
<h2>数字游民签证指南</h2>
|
||||||
|
<p>全球热门远程工作签证政策一览,助你合法旅居</p>
|
||||||
|
</div>
|
||||||
|
<div class="visa-grid">
|
||||||
|
<div class="visa-card reveal">
|
||||||
|
<div class="visa-flag">🇵🇹</div>
|
||||||
|
<h3>葡萄牙 D7 签证</h3>
|
||||||
|
<div class="visa-badge easy">⭐ 推荐</div>
|
||||||
|
<ul class="visa-details">
|
||||||
|
<li>📅 有效期:2年,可续签</li>
|
||||||
|
<li>💰 收入要求:€760/月</li>
|
||||||
|
<li>⏱️ 审批:3-6 个月</li>
|
||||||
|
<li>🏥 可享欧盟医疗</li>
|
||||||
|
</ul>
|
||||||
|
<div class="visa-progress">
|
||||||
|
<span>难度</span>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:35%"></div></div>
|
||||||
|
<span class="progress-label">简单</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="visa-card reveal">
|
||||||
|
<div class="visa-flag">🇪🇸</div>
|
||||||
|
<h3>西班牙 Nomad Visa</h3>
|
||||||
|
<div class="visa-badge hot">🔥 热门</div>
|
||||||
|
<ul class="visa-details">
|
||||||
|
<li>📅 有效期:1年,可续3年</li>
|
||||||
|
<li>💰 收入要求:€2,160/月</li>
|
||||||
|
<li>⏱️ 审批:1-3 个月</li>
|
||||||
|
<li>🌍 可申根区旅行</li>
|
||||||
|
</ul>
|
||||||
|
<div class="visa-progress">
|
||||||
|
<span>难度</span>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:45%"></div></div>
|
||||||
|
<span class="progress-label">中等</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="visa-card reveal">
|
||||||
|
<div class="visa-flag">🇮🇩</div>
|
||||||
|
<h3>印尼 B211A 签证</h3>
|
||||||
|
<div class="visa-badge budget">💰 低成本</div>
|
||||||
|
<ul class="visa-details">
|
||||||
|
<li>📅 有效期:60天,可延期</li>
|
||||||
|
<li>💰 费用:约 ¥2,000</li>
|
||||||
|
<li>⏱️ 审批:5-10 天</li>
|
||||||
|
<li>🏝️ 适合巴厘岛旅居</li>
|
||||||
|
</ul>
|
||||||
|
<div class="visa-progress">
|
||||||
|
<span>难度</span>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:25%"></div></div>
|
||||||
|
<span class="progress-label">简单</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="visa-card reveal">
|
||||||
|
<div class="visa-flag">🇹🇭</div>
|
||||||
|
<h3>泰国 LTR 签证</h3>
|
||||||
|
<div class="visa-badge new">🆕 新政策</div>
|
||||||
|
<ul class="visa-details">
|
||||||
|
<li>📅 有效期:10年</li>
|
||||||
|
<li>💰 收入要求:$80,000/年</li>
|
||||||
|
<li>⏱️ 审批:1-2 个月</li>
|
||||||
|
<li>✈️ 多次入境</li>
|
||||||
|
</ul>
|
||||||
|
<div class="visa-progress">
|
||||||
|
<span>难度</span>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:60%"></div></div>
|
||||||
|
<span class="progress-label">中等</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="visa-card reveal">
|
||||||
|
<div class="visa-flag">🇲🇽</div>
|
||||||
|
<h3>墨西哥 Temporary Resident</h3>
|
||||||
|
<div class="visa-badge budget">💰 低成本</div>
|
||||||
|
<ul class="visa-details">
|
||||||
|
<li>📅 有效期:1-4年</li>
|
||||||
|
<li>💰 收入要求:$2,500/月</li>
|
||||||
|
<li>⏱️ 审批:2-4 周</li>
|
||||||
|
<li>🌮 北美时区友好</li>
|
||||||
|
</ul>
|
||||||
|
<div class="visa-progress">
|
||||||
|
<span>难度</span>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:30%"></div></div>
|
||||||
|
<span class="progress-label">简单</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="visa-card reveal">
|
||||||
|
<div class="visa-flag">🇪🇪</div>
|
||||||
|
<h3>爱沙尼亚 DNV</h3>
|
||||||
|
<div class="visa-badge pioneer">🚀 先锋</div>
|
||||||
|
<ul class="visa-details">
|
||||||
|
<li>📅 有效期:1年</li>
|
||||||
|
<li>💰 收入要求:€3,504/月</li>
|
||||||
|
<li>⏱️ 审批:2-4 周</li>
|
||||||
|
<li>💻 全球首个数字游民签证</li>
|
||||||
|
</ul>
|
||||||
|
<div class="visa-progress">
|
||||||
|
<span>难度</span>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:40%"></div></div>
|
||||||
|
<span class="progress-label">中等</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 工具箱 -->
|
||||||
|
<section class="section tools" id="tools">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<span class="section-tag">🛠️ TOOLKIT</span>
|
||||||
|
<h2>游民必备工具箱</h2>
|
||||||
|
<p>精选远程工作与旅居生活的效率神器</p>
|
||||||
|
</div>
|
||||||
|
<div class="tools-grid">
|
||||||
|
<div class="tool-card reveal" data-category="work">
|
||||||
|
<div class="tool-icon-wrap">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none"><rect x="6" y="10" width="36" height="26" rx="3" stroke="currentColor" stroke-width="2"/><path d="M6 18h36" stroke="currentColor" stroke-width="2"/><circle cx="24" cy="30" r="3" fill="currentColor"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="tool-emoji">💼</span>
|
||||||
|
<h3>远程协作</h3>
|
||||||
|
<p>Slack · Notion · Figma · Zoom</p>
|
||||||
|
<div class="tool-tags">
|
||||||
|
<span>团队</span><span>设计</span><span>沟通</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card reveal" data-category="travel">
|
||||||
|
<div class="tool-icon-wrap">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none"><path d="M24 4L8 14v20l16 10 16-10V14L24 4z" stroke="currentColor" stroke-width="2"/><path d="M8 14l16 10 16-10M24 24v20" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="tool-emoji">✈️</span>
|
||||||
|
<h3>旅行规划</h3>
|
||||||
|
<p>Skyscanner · Nomad List · SafetyWing</p>
|
||||||
|
<div class="tool-tags">
|
||||||
|
<span>机票</span><span>签证</span><span>保险</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card reveal" data-category="finance">
|
||||||
|
<div class="tool-icon-wrap">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="18" stroke="currentColor" stroke-width="2"/><path d="M24 14v20M18 20h10a4 4 0 010 8h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="tool-emoji">💳</span>
|
||||||
|
<h3>财务管理</h3>
|
||||||
|
<p>Wise · Revolut · Xero · 多币种账户</p>
|
||||||
|
<div class="tool-tags">
|
||||||
|
<span>汇款</span><span>记账</span><span>税务</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card reveal" data-category="connect">
|
||||||
|
<div class="tool-icon-wrap">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none"><circle cx="16" cy="20" r="6" stroke="currentColor" stroke-width="2"/><circle cx="32" cy="20" r="6" stroke="currentColor" stroke-width="2"/><path d="M8 38c0-6 4-10 8-10s8 4 8 10M24 38c0-6 4-10 8-10s8 4 8 10" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="tool-emoji">🤝</span>
|
||||||
|
<h3>社群网络</h3>
|
||||||
|
<p>Nomad List · Remote Year · 本地 Meetup</p>
|
||||||
|
<div class="tool-tags">
|
||||||
|
<span>社交</span><span>活动</span><span>合租</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card reveal" data-category="health">
|
||||||
|
<div class="tool-icon-wrap">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none"><path d="M24 8v32M8 24h32" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/><circle cx="24" cy="24" r="18" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="tool-emoji">🏥</span>
|
||||||
|
<h3>健康保障</h3>
|
||||||
|
<p>SafetyWing · World Nomads · 运动 App</p>
|
||||||
|
<div class="tool-tags">
|
||||||
|
<span>保险</span><span>健身</span><span>心理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card reveal" data-category="learn">
|
||||||
|
<div class="tool-icon-wrap">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none"><path d="M6 16l18-8 18 8-18 8-18-8z" stroke="currentColor" stroke-width="2"/><path d="M12 22v10c0 4 6 8 12 8s12-4 12-8V22" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="tool-emoji">📚</span>
|
||||||
|
<h3>持续学习</h3>
|
||||||
|
<p>Coursera · Duolingo · 当地语言班</p>
|
||||||
|
<div class="tool-tags">
|
||||||
|
<span>技能</span><span>语言</span><span>文化</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<section class="section faq-section" id="faq">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<span class="section-tag">❓ FAQ</span>
|
||||||
|
<h2>常见问题</h2>
|
||||||
|
<p>关于数字游民生活,你可能想知道的一切</p>
|
||||||
|
</div>
|
||||||
|
<div class="faq-list">
|
||||||
|
<div class="faq-item reveal">
|
||||||
|
<button class="faq-question">
|
||||||
|
<span>💰 做数字游民需要多少启动资金?</span>
|
||||||
|
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>建议准备 3-6 个月的生活费作为缓冲。以东南亚为例,¥15,000-30,000 即可开始。包括机票、首月住宿、签证费用和应急资金。欧洲目的地建议准备 ¥50,000 以上。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item reveal">
|
||||||
|
<button class="faq-question">
|
||||||
|
<span>📶 如何确保远程工作的网络稳定?</span>
|
||||||
|
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>选择网络评分高的城市,入住前用 Speedtest 测试。备用方案:本地 SIM 卡热点、随身 WiFi 设备、附近 Co-working Space。推荐携带 USB 网卡和 VPN 作为双保险。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item reveal">
|
||||||
|
<button class="faq-question">
|
||||||
|
<span>🏥 旅居期间的保险怎么办?</span>
|
||||||
|
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>推荐 SafetyWing 或 World Nomads 等国际医疗保险,月费约 $40-80,覆盖全球(部分国家除外)。长期旅居者可考虑目的地国家的本地保险,费用更低、报销更方便。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item reveal">
|
||||||
|
<button class="faq-question">
|
||||||
|
<span>🧾 税务问题如何处理?</span>
|
||||||
|
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>税务居民身份取决于居住天数(通常 183 天规则)。建议咨询专业税务顾问,了解双重征税协定。很多游民选择税务友好的国家(如葡萄牙、格鲁吉亚)作为基地。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item reveal">
|
||||||
|
<button class="faq-question">
|
||||||
|
<span>👨👩👧 可以带娃一起做数字游民吗?</span>
|
||||||
|
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>完全可以!巴厘岛、清迈、里斯本都有成熟的数字游民家庭社区。关键是选择教育资源丰富、医疗条件好的目的地,以及保持稳定的工作节奏,给孩子规律的生活。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item reveal">
|
||||||
|
<button class="faq-question">
|
||||||
|
<span>🤝 如何快速融入当地游民社区?</span>
|
||||||
|
<svg class="faq-chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>加入 Nomad List、Facebook 群组和本地 Meetup 活动。入住游民友好的 Co-living 空间,参加每周的 Coworking 社交日。大部分游民社区非常开放,主动打招呼就能结识朋友。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 社区 -->
|
||||||
|
<section class="section community" id="community">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<span class="section-tag">🤝 COMMUNITY</span>
|
||||||
|
<h2>游民心声</h2>
|
||||||
|
<p>来自全球数字游民的真实故事</p>
|
||||||
|
</div>
|
||||||
|
<div class="testimonials">
|
||||||
|
<div class="testimonial-card reveal">
|
||||||
|
<div class="testimonial-avatar">👩💻</div>
|
||||||
|
<div class="testimonial-content">
|
||||||
|
<div class="testimonial-stars">⭐⭐⭐⭐⭐</div>
|
||||||
|
<p>"在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。"</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<strong>小林</strong>
|
||||||
|
<span>前端开发 · 清迈 🇹🇭</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="testimonial-card reveal">
|
||||||
|
<div class="testimonial-avatar">👨🎨</div>
|
||||||
|
<div class="testimonial-content">
|
||||||
|
<div class="testimonial-stars">⭐⭐⭐⭐⭐</div>
|
||||||
|
<p>"里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。"</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<strong>Marco</strong>
|
||||||
|
<span>UI 设计师 · 里斯本 🇵🇹</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="testimonial-card reveal">
|
||||||
|
<div class="testimonial-avatar">🧑💼</div>
|
||||||
|
<div class="testimonial-content">
|
||||||
|
<div class="testimonial-stars">⭐⭐⭐⭐⭐</div>
|
||||||
|
<p>"带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。"</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<strong>张家</strong>
|
||||||
|
<span>产品经理 · 巴厘岛 🇮🇩</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-banner reveal">
|
||||||
|
<div class="cta-content">
|
||||||
|
<h2>准备好开始你的旅居之旅了吗? 🌍</h2>
|
||||||
|
<p>加入 50,000+ 数字游民社区,获取目的地指南、签证攻略与独家优惠</p>
|
||||||
|
</div>
|
||||||
|
<form class="cta-form" id="subscribe-form">
|
||||||
|
<input type="email" placeholder="输入你的邮箱 📧" required />
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<span>免费订阅</span>
|
||||||
|
<span>🚀</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<a href="#" class="logo">
|
||||||
|
<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#logoGrad2)" stroke-width="2.5"/><path d="M12 22c4-8 12-8 16 0" stroke="url(#logoGrad2)" stroke-width="2" stroke-linecap="round"/><circle cx="28" cy="14" r="3" fill="url(#logoGrad2)"/><defs><linearGradient id="logoGrad2" x1="0" y1="0" x2="40" y2="40"><stop offset="0%" stop-color="#FF6B6B"/><stop offset="100%" stop-color="#4ECDC4"/></linearGradient></defs></svg>
|
||||||
|
<span>NomadFlow</span>
|
||||||
|
</a>
|
||||||
|
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
|
||||||
|
<div class="social-links">
|
||||||
|
<a href="#" aria-label="Twitter">𝕏</a>
|
||||||
|
<a href="#" aria-label="Instagram">📷</a>
|
||||||
|
<a href="#" aria-label="YouTube">▶️</a>
|
||||||
|
<a href="#" aria-label="Discord">💬</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>探索</h4>
|
||||||
|
<a href="#destinations">目的地</a>
|
||||||
|
<a href="#visa">签证指南</a>
|
||||||
|
<a href="#data">数据报告</a>
|
||||||
|
<a href="#faq">常见问题</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>资源</h4>
|
||||||
|
<a href="#">签证指南</a>
|
||||||
|
<a href="#">税务攻略</a>
|
||||||
|
<a href="#">保险对比</a>
|
||||||
|
<a href="#">博客</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>联系</h4>
|
||||||
|
<a href="#">关于我们</a>
|
||||||
|
<a href="#">合作伙伴</a>
|
||||||
|
<a href="#">隐私政策</a>
|
||||||
|
<a href="#">hello@nomadflow.io</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p>© 2026 NomadFlow. Made with ❤️ for Digital Nomads everywhere.</p>
|
||||||
|
<p class="footer-emoji-bar">🌴 ☀️ 🏄♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div class="toast" id="toast">
|
||||||
|
<span class="toast-icon">✅</span>
|
||||||
|
<span class="toast-msg">订阅成功!欢迎加入 NomadFlow 社区 🎉</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 目的地详情弹窗 -->
|
||||||
|
<div class="modal-overlay" id="modal-overlay">
|
||||||
|
<div class="modal" id="dest-modal">
|
||||||
|
<button class="modal-close" id="modal-close" aria-label="关闭">✕</button>
|
||||||
|
<div class="modal-content" id="modal-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 返回顶部 -->
|
||||||
|
<button class="back-to-top" id="back-to-top" aria-label="返回顶部">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
776
js/main.js
Normal file
776
js/main.js
Normal file
@ -0,0 +1,776 @@
|
|||||||
|
/* ===== NomadFlow - Main JavaScript ===== */
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initLoader();
|
||||||
|
initParticles();
|
||||||
|
initNavbar();
|
||||||
|
initThemeToggle();
|
||||||
|
initMobileMenu();
|
||||||
|
initScrollReveal();
|
||||||
|
initCounters();
|
||||||
|
initTiltEffect();
|
||||||
|
initCharts();
|
||||||
|
initSubscribeForm();
|
||||||
|
initTypewriter();
|
||||||
|
initMapPins();
|
||||||
|
initDestFilters();
|
||||||
|
initDestModal();
|
||||||
|
initFAQ();
|
||||||
|
initBackToTop();
|
||||||
|
initNavHighlight();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ----- Loader ----- */
|
||||||
|
function initLoader() {
|
||||||
|
const loader = document.getElementById('loader');
|
||||||
|
if (!loader) return;
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
setTimeout(() => loader.classList.add('hidden'), 1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => loader.classList.add('hidden'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Typewriter ----- */
|
||||||
|
function initTypewriter() {
|
||||||
|
const el = document.getElementById('typewriter');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const phrases = [
|
||||||
|
'数字游民不是逃离生活,而是用科技重新定义生活。',
|
||||||
|
'在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️',
|
||||||
|
'世界那么大,你的办公室可以在任何地方 🌍',
|
||||||
|
'远程工作 + 全球旅居 = 无限可能 ✨',
|
||||||
|
];
|
||||||
|
|
||||||
|
let phraseIdx = 0;
|
||||||
|
let charIdx = 0;
|
||||||
|
let deleting = false;
|
||||||
|
|
||||||
|
function type() {
|
||||||
|
const current = phrases[phraseIdx];
|
||||||
|
|
||||||
|
if (!deleting) {
|
||||||
|
el.textContent = current.substring(0, charIdx + 1);
|
||||||
|
charIdx++;
|
||||||
|
if (charIdx === current.length) {
|
||||||
|
deleting = true;
|
||||||
|
setTimeout(type, 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
el.textContent = current.substring(0, charIdx - 1);
|
||||||
|
charIdx--;
|
||||||
|
if (charIdx === 0) {
|
||||||
|
deleting = false;
|
||||||
|
phraseIdx = (phraseIdx + 1) % phrases.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(type, deleting ? 30 : 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(type, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- City Data ----- */
|
||||||
|
const CITY_DATA = {
|
||||||
|
bali: {
|
||||||
|
name: '巴厘岛, 印尼', emoji: '🏝️', tag: '东南亚 · 热带天堂',
|
||||||
|
desc: '乌布的数字游民社区闻名全球,稻田间的 Co-working Space 和瑜伽文化让这里成为游民圣地。',
|
||||||
|
cost: '¥4,500', speed: '85Mbps', temp: '28°C', rating: '9.2',
|
||||||
|
nomads: '12,000+', highlights: ['🏄 冲浪与海滩生活', '🧘 瑜伽冥想文化', '💰 东南亚性价比之王', '🌴 热带气候全年温暖'],
|
||||||
|
hue: 170,
|
||||||
|
},
|
||||||
|
lisbon: {
|
||||||
|
name: '里斯本, 葡萄牙', emoji: '🌊', tag: '欧洲 · 海滨明珠',
|
||||||
|
desc: 'D7 签证友好,阳光海岸与悠久历史的完美融合,欧洲数字游民的首选基地。',
|
||||||
|
cost: '¥9,000', speed: '120Mbps', temp: '22°C', rating: '9.5',
|
||||||
|
nomads: '8,500+', highlights: ['📋 D7 签证门槛低', '☀️ 300天阳光', '🎵 Fado 音乐文化', '🚋 复古有轨电车'],
|
||||||
|
hue: 220,
|
||||||
|
},
|
||||||
|
chiangmai: {
|
||||||
|
name: '清迈, 泰国', emoji: '🏔️', tag: '东南亚 · 文化古城',
|
||||||
|
desc: '数字游民大本营,咖啡文化与夜市生活的天堂,全球性价比最高的游民城市。',
|
||||||
|
cost: '¥3,800', speed: '95Mbps', temp: '30°C', rating: '9.4',
|
||||||
|
nomads: '15,000+', highlights: ['☕ 咖啡馆文化浓厚', '🏮 夜市与寺庙', '💰 月生活费最低', '🤝 游民社区最活跃'],
|
||||||
|
hue: 45,
|
||||||
|
},
|
||||||
|
mexico: {
|
||||||
|
name: '墨西哥城, 墨西哥', emoji: '🌃', tag: '拉美 · 活力之都',
|
||||||
|
desc: '艺术、美食与科技交织,时区便利对接北美市场,拉美最具活力的游民城市。',
|
||||||
|
cost: '¥6,500', speed: '75Mbps', temp: '18°C', rating: '8.8',
|
||||||
|
nomads: '5,200+', highlights: ['🎨 街头艺术天堂', '🌮 世界美食之都', '🕐 北美时区友好', '💃 丰富夜生活'],
|
||||||
|
hue: 300,
|
||||||
|
},
|
||||||
|
barcelona: {
|
||||||
|
name: '巴塞罗那, 西班牙', emoji: '🏖️', tag: '欧洲 · 地中海',
|
||||||
|
desc: '高迪建筑与创业生态并存,Nomad Visa 政策领先,地中海生活的理想之选。',
|
||||||
|
cost: '¥10,500', speed: '150Mbps', temp: '20°C', rating: '9.1',
|
||||||
|
nomads: '6,800+', highlights: ['🏛️ 高迪建筑奇迹', '🏖️ 地中海海滩', '📋 Nomad Visa 便利', '🍷 美食与夜生活'],
|
||||||
|
hue: 130,
|
||||||
|
},
|
||||||
|
tokyo: {
|
||||||
|
name: '东京, 日本', emoji: '🗼', tag: '亚洲 · 现代都市',
|
||||||
|
desc: '极致效率与安全,适合追求高品质生活的远程工作者,亚洲科技之都。',
|
||||||
|
cost: '¥12,000', speed: '200Mbps', temp: '15°C', rating: '8.6',
|
||||||
|
nomads: '4,100+', highlights: ['🚄 极致公共交通', '🛡️ 全球最安全城市', '📶 网速亚洲第一', '🍣 美食文化巅峰'],
|
||||||
|
hue: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----- Map Pins ----- */
|
||||||
|
function initMapPins() {
|
||||||
|
const pins = document.querySelectorAll('.map-pin');
|
||||||
|
const panel = document.getElementById('map-info-panel');
|
||||||
|
if (!pins.length || !panel) return;
|
||||||
|
|
||||||
|
pins.forEach((pin) => {
|
||||||
|
pin.addEventListener('click', () => {
|
||||||
|
const cityId = pin.dataset.city;
|
||||||
|
const city = CITY_DATA[cityId];
|
||||||
|
if (!city) return;
|
||||||
|
|
||||||
|
pins.forEach((p) => p.classList.remove('active'));
|
||||||
|
pin.classList.add('active');
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="map-info-city">
|
||||||
|
<div class="city-emoji">${city.emoji}</div>
|
||||||
|
<h3>${city.name}</h3>
|
||||||
|
<span class="modal-tag">${city.tag}</span>
|
||||||
|
<p class="city-desc">${city.desc}</p>
|
||||||
|
<div class="map-info-stats">
|
||||||
|
<div class="map-stat"><strong>${city.cost}</strong>月生活费</div>
|
||||||
|
<div class="map-stat"><strong>${city.speed}</strong>网速</div>
|
||||||
|
<div class="map-stat"><strong>${city.temp}</strong>均温</div>
|
||||||
|
<div class="map-stat"><strong>⭐ ${city.rating}</strong>评分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Destination Filters ----- */
|
||||||
|
function initDestFilters() {
|
||||||
|
const grid = document.getElementById('dest-grid');
|
||||||
|
const search = document.getElementById('dest-search');
|
||||||
|
const sort = document.getElementById('dest-sort');
|
||||||
|
const empty = document.getElementById('dest-empty');
|
||||||
|
const filterBtns = document.querySelectorAll('.filter-btn');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
let currentFilter = 'all';
|
||||||
|
|
||||||
|
function getCards() {
|
||||||
|
return [...grid.querySelectorAll('.dest-card')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const query = (search?.value || '').toLowerCase();
|
||||||
|
const cards = getCards();
|
||||||
|
let visible = 0;
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
const name = card.querySelector('h3')?.textContent.toLowerCase() || '';
|
||||||
|
const region = card.dataset.region;
|
||||||
|
const matchFilter = currentFilter === 'all' || region === currentFilter;
|
||||||
|
const matchSearch = !query || name.includes(query);
|
||||||
|
const show = matchFilter && matchSearch;
|
||||||
|
|
||||||
|
card.classList.toggle('hidden-card', !show);
|
||||||
|
if (show) visible++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (empty) empty.hidden = visible > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySort() {
|
||||||
|
const cards = getCards();
|
||||||
|
const sortVal = sort?.value || 'rating';
|
||||||
|
|
||||||
|
cards.sort((a, b) => {
|
||||||
|
switch (sortVal) {
|
||||||
|
case 'cost-asc': return +a.dataset.cost - +b.dataset.cost;
|
||||||
|
case 'cost-desc': return +b.dataset.cost - +a.dataset.cost;
|
||||||
|
case 'speed': return +b.dataset.speed - +a.dataset.speed;
|
||||||
|
default: return +b.dataset.rating - +a.dataset.rating;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cards.forEach((card) => grid.appendChild(card));
|
||||||
|
}
|
||||||
|
|
||||||
|
filterBtns.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBtns.forEach((b) => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentFilter = btn.dataset.filter;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
search?.addEventListener('input', applyFilters);
|
||||||
|
sort?.addEventListener('change', () => { applySort(); applyFilters(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Destination Modal ----- */
|
||||||
|
function initDestModal() {
|
||||||
|
const overlay = document.getElementById('modal-overlay');
|
||||||
|
const content = document.getElementById('modal-content');
|
||||||
|
const closeBtn = document.getElementById('modal-close');
|
||||||
|
const cards = document.querySelectorAll('.dest-card');
|
||||||
|
if (!overlay || !content) return;
|
||||||
|
|
||||||
|
function openModal(cityId) {
|
||||||
|
const city = CITY_DATA[cityId];
|
||||||
|
if (!city) return;
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="modal-hero" style="background:linear-gradient(135deg,hsl(${city.hue},60%,20%),hsl(${city.hue + 40},50%,30%))">
|
||||||
|
${city.emoji}
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<h2>${city.name}</h2>
|
||||||
|
<span class="modal-tag">${city.tag}</span>
|
||||||
|
<p>${city.desc}</p>
|
||||||
|
<div class="modal-stats">
|
||||||
|
<div class="modal-stat"><span class="stat-emoji">💰</span><strong>${city.cost}</strong><span>月生活费</span></div>
|
||||||
|
<div class="modal-stat"><span class="stat-emoji">📶</span><strong>${city.speed}</strong><span>网速</span></div>
|
||||||
|
<div class="modal-stat"><span class="stat-emoji">🌡️</span><strong>${city.temp}</strong><span>均温</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-highlights">
|
||||||
|
<h4>✨ 亮点特色</h4>
|
||||||
|
<ul>${city.highlights.map((h) => `<li>${h}</li>`).join('')}</ul>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:0.8rem;color:var(--text-muted)">👥 活跃游民: ${city.nomads} · ⭐ 评分: ${city.rating}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
overlay.classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
card.addEventListener('click', () => openModal(card.dataset.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn?.addEventListener('click', closeModal);
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) closeModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- FAQ Accordion ----- */
|
||||||
|
function initFAQ() {
|
||||||
|
const items = document.querySelectorAll('.faq-item');
|
||||||
|
items.forEach((item) => {
|
||||||
|
const btn = item.querySelector('.faq-question');
|
||||||
|
btn?.addEventListener('click', () => {
|
||||||
|
const wasActive = item.classList.contains('active');
|
||||||
|
items.forEach((i) => i.classList.remove('active'));
|
||||||
|
if (!wasActive) item.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Back to Top ----- */
|
||||||
|
function initBackToTop() {
|
||||||
|
const btn = document.getElementById('back-to-top');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
btn.classList.toggle('visible', window.scrollY > 600);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Nav Section Highlight ----- */
|
||||||
|
function initNavHighlight() {
|
||||||
|
const links = document.querySelectorAll('.nav-links a[data-section]');
|
||||||
|
const sections = [...links].map((l) => document.getElementById(l.dataset.section)).filter(Boolean);
|
||||||
|
if (!sections.length) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const id = entry.target.id;
|
||||||
|
links.forEach((l) => l.classList.toggle('active', l.dataset.section === id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.3, rootMargin: '-80px 0px -50% 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.forEach((s) => observer.observe(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initParticles() {
|
||||||
|
const canvas = document.getElementById('particle-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let particles = [];
|
||||||
|
let animId;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticles() {
|
||||||
|
const count = Math.min(Math.floor(window.innerWidth / 12), 120);
|
||||||
|
particles = Array.from({ length: count }, () => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
size: Math.random() * 2 + 0.5,
|
||||||
|
speedX: (Math.random() - 0.5) * 0.3,
|
||||||
|
speedY: (Math.random() - 0.5) * 0.3,
|
||||||
|
opacity: Math.random() * 0.5 + 0.1,
|
||||||
|
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
particles.forEach((p, i) => {
|
||||||
|
p.x += p.speedX;
|
||||||
|
p.y += p.speedY;
|
||||||
|
|
||||||
|
if (p.x < 0) p.x = canvas.width;
|
||||||
|
if (p.x > canvas.width) p.x = 0;
|
||||||
|
if (p.y < 0) p.y = canvas.height;
|
||||||
|
if (p.y > canvas.height) p.y = 0;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
particles.slice(i + 1).forEach((p2) => {
|
||||||
|
const dx = p.x - p2.x;
|
||||||
|
const dy = p.y - p2.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
ctx.lineTo(p2.x, p2.y);
|
||||||
|
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize();
|
||||||
|
createParticles();
|
||||||
|
draw();
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
resize();
|
||||||
|
createParticles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Navbar Scroll Effect ----- */
|
||||||
|
function initNavbar() {
|
||||||
|
const navbar = document.getElementById('navbar');
|
||||||
|
if (!navbar) return;
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
navbar.classList.toggle('scrolled', window.scrollY > 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Theme Toggle ----- */
|
||||||
|
function initThemeToggle() {
|
||||||
|
const btn = document.getElementById('theme-toggle');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('nomad-theme');
|
||||||
|
if (saved === 'light') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
btn.querySelector('.theme-icon').textContent = '☀️';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||||
|
if (isLight) {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
btn.querySelector('.theme-icon').textContent = '🌙';
|
||||||
|
localStorage.setItem('nomad-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
btn.querySelector('.theme-icon').textContent = '☀️';
|
||||||
|
localStorage.setItem('nomad-theme', 'light');
|
||||||
|
}
|
||||||
|
updateChartThemes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Mobile Menu ----- */
|
||||||
|
function initMobileMenu() {
|
||||||
|
const btn = document.getElementById('mobile-menu-btn');
|
||||||
|
const links = document.querySelector('.nav-links');
|
||||||
|
if (!btn || !links) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
links.classList.toggle('open');
|
||||||
|
btn.classList.toggle('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
links.querySelectorAll('a').forEach((a) => {
|
||||||
|
a.addEventListener('click', () => {
|
||||||
|
links.classList.remove('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Scroll Reveal ----- */
|
||||||
|
function initScrollReveal() {
|
||||||
|
const reveals = document.querySelectorAll('.reveal');
|
||||||
|
if (!reveals.length) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry, i) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const delay = entry.target.dataset.delay || 0;
|
||||||
|
setTimeout(() => {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
}, delay);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
reveals.forEach((el, i) => {
|
||||||
|
el.style.transitionDelay = `${(i % 6) * 0.1}s`;
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Animated Counters ----- */
|
||||||
|
function initCounters() {
|
||||||
|
const counters = document.querySelectorAll('.stat-number');
|
||||||
|
if (!counters.length) return;
|
||||||
|
|
||||||
|
const animate = (el) => {
|
||||||
|
const target = parseInt(el.dataset.target, 10);
|
||||||
|
const duration = 2000;
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const step = (now) => {
|
||||||
|
const progress = Math.min((now - start) / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
el.textContent = Math.floor(eased * target);
|
||||||
|
if (progress < 1) requestAnimationFrame(step);
|
||||||
|
else el.textContent = target;
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
animate(entry.target);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 }
|
||||||
|
);
|
||||||
|
|
||||||
|
counters.forEach((c) => observer.observe(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- 3D Tilt Effect ----- */
|
||||||
|
function initTiltEffect() {
|
||||||
|
const cards = document.querySelectorAll('[data-tilt]');
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
card.addEventListener('mousemove', (e) => {
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const centerX = rect.width / 2;
|
||||||
|
const centerY = rect.height / 2;
|
||||||
|
const rotateX = ((y - centerY) / centerY) * -8;
|
||||||
|
const rotateY = ((x - centerX) / centerX) * 8;
|
||||||
|
|
||||||
|
card.style.transform = `perspective(800px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateY(-8px)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseleave', () => {
|
||||||
|
card.style.transform = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Charts ----- */
|
||||||
|
let chartInstances = {};
|
||||||
|
|
||||||
|
function getChartColors() {
|
||||||
|
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||||
|
return {
|
||||||
|
text: isLight ? '#475569' : '#94a3b8',
|
||||||
|
grid: isLight ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.06)',
|
||||||
|
accent: ['#FF6B6B', '#FFE66D', '#4ECDC4', '#A78BFA', '#F472B6', '#60A5FA'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCharts() {
|
||||||
|
if (typeof Chart === 'undefined') return;
|
||||||
|
|
||||||
|
Chart.defaults.font.family = "'Outfit', 'Noto Sans SC', sans-serif";
|
||||||
|
Chart.defaults.color = getChartColors().text;
|
||||||
|
|
||||||
|
initCostChart();
|
||||||
|
initSpeedChart();
|
||||||
|
initGrowthChart();
|
||||||
|
initRadarChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCostChart() {
|
||||||
|
const ctx = document.getElementById('costChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
const colors = getChartColors();
|
||||||
|
|
||||||
|
chartInstances.cost = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: ['清迈 🇹🇭', '巴厘岛 🇮🇩', '墨西哥城 🇲🇽', '里斯本 🇵🇹', '巴塞罗那 🇪🇸', '东京 🇯🇵'],
|
||||||
|
datasets: [{
|
||||||
|
label: '月生活费 (千元人民币)',
|
||||||
|
data: [3.8, 4.5, 6.5, 9.0, 10.5, 12.0],
|
||||||
|
backgroundColor: colors.accent.map((c) => c + '99'),
|
||||||
|
borderColor: colors.accent,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderSkipped: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 2000, easing: 'easeOutQuart' },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17, 24, 39, 0.9)',
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => ` ¥${ctx.parsed.y.toFixed(1)},000/月`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { color: colors.grid },
|
||||||
|
ticks: { callback: (v) => `¥${v}k` },
|
||||||
|
},
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSpeedChart() {
|
||||||
|
const ctx = document.getElementById('speedChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
const colors = getChartColors();
|
||||||
|
|
||||||
|
chartInstances.speed = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['东京 200', '巴塞罗那 150', '里斯本 120', '清迈 95', '巴厘岛 85', '墨西哥城 75'],
|
||||||
|
datasets: [{
|
||||||
|
data: [200, 150, 120, 95, 85, 75],
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 12,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '65%',
|
||||||
|
animation: { animateRotate: true, duration: 2000 },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: { padding: 12, usePointStyle: true, pointStyle: 'circle' },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: { label: (ctx) => ` ${ctx.parsed} Mbps` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGrowthChart() {
|
||||||
|
const ctx = document.getElementById('growthChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
const colors = getChartColors();
|
||||||
|
|
||||||
|
chartInstances.growth = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: ['2019', '2020', '2021', '2022', '2023', '2024', '2025', '2026'],
|
||||||
|
datasets: [{
|
||||||
|
label: '全球数字游民 (百万人)',
|
||||||
|
data: [7.3, 10.9, 15.5, 24.0, 28.5, 31.2, 33.8, 35.6],
|
||||||
|
borderColor: colors.accent[2],
|
||||||
|
backgroundColor: colors.accent[2] + '20',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 5,
|
||||||
|
pointBackgroundColor: colors.accent[2],
|
||||||
|
pointBorderColor: '#0a0e17',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointHoverRadius: 8,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 2500, easing: 'easeOutQuart' },
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: { label: (ctx) => ` ${ctx.parsed.y}M 人` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { color: colors.grid },
|
||||||
|
ticks: { callback: (v) => `${v}M` },
|
||||||
|
},
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRadarChart() {
|
||||||
|
const ctx = document.getElementById('radarChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
const colors = getChartColors();
|
||||||
|
|
||||||
|
chartInstances.radar = new Chart(ctx, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
labels: ['生活成本 💰', '网络速度 📶', '安全性 🛡️', '社群活跃 🤝', '气候环境 🌤️', '签证便利 📋'],
|
||||||
|
datasets: [{
|
||||||
|
label: '清迈',
|
||||||
|
data: [95, 80, 85, 95, 70, 90],
|
||||||
|
borderColor: colors.accent[2],
|
||||||
|
backgroundColor: colors.accent[2] + '30',
|
||||||
|
pointBackgroundColor: colors.accent[2],
|
||||||
|
}, {
|
||||||
|
label: '里斯本',
|
||||||
|
data: [60, 90, 92, 80, 85, 95],
|
||||||
|
borderColor: colors.accent[0],
|
||||||
|
backgroundColor: colors.accent[0] + '30',
|
||||||
|
pointBackgroundColor: colors.accent[0],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 2000 },
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100,
|
||||||
|
grid: { color: colors.grid },
|
||||||
|
angleLines: { color: colors.grid },
|
||||||
|
pointLabels: { font: { size: 11 } },
|
||||||
|
ticks: { display: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { padding: 16, usePointStyle: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChartThemes() {
|
||||||
|
const colors = getChartColors();
|
||||||
|
Chart.defaults.color = colors.text;
|
||||||
|
|
||||||
|
Object.values(chartInstances).forEach((chart) => {
|
||||||
|
if (!chart) return;
|
||||||
|
if (chart.options.scales?.y) chart.options.scales.y.grid.color = colors.grid;
|
||||||
|
if (chart.options.scales?.x) chart.options.scales.x.grid = { color: colors.grid, display: chart.options.scales.x.grid?.display ?? false };
|
||||||
|
if (chart.options.scales?.r) {
|
||||||
|
chart.options.scales.r.grid.color = colors.grid;
|
||||||
|
chart.options.scales.r.angleLines.color = colors.grid;
|
||||||
|
}
|
||||||
|
chart.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Subscribe Form ----- */
|
||||||
|
function initSubscribeForm() {
|
||||||
|
const form = document.getElementById('subscribe-form');
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
if (!form || !toast) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = form.querySelector('input');
|
||||||
|
if (!input.value) return;
|
||||||
|
|
||||||
|
toast.classList.add('show');
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
setTimeout(() => toast.classList.remove('show'), 4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Smooth anchor highlight ----- */
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
|
||||||
|
anchor.addEventListener('click', (e) => {
|
||||||
|
const target = document.querySelector(anchor.getAttribute('href'));
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
target.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
12
package.json
Normal file
12
package.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "nomadflow",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1",
|
||||||
|
"dev:api": "cd backend && uvicorn app.main:app --reload --port 8000",
|
||||||
|
"dev:web": "cd frontend && npm run dev",
|
||||||
|
"install:all": "cd backend && pip install -r requirements.txt && cd ../frontend && npm install",
|
||||||
|
"seed": "cd backend && python seed_pocketbase.py",
|
||||||
|
"build:web": "cd frontend && npm run build"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
pocketbase/pb_data/.gitkeep
Normal file
0
pocketbase/pb_data/.gitkeep
Normal file
32
scripts/dev.ps1
Normal file
32
scripts/dev.ps1
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# NomadFlow 本地开发启动脚本 (Windows PowerShell)
|
||||||
|
# 用法: .\scripts\dev.ps1
|
||||||
|
|
||||||
|
Write-Host "🌍 NomadFlow 开发环境启动" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 检查后端依赖
|
||||||
|
if (-not (Test-Path "backend\.venv")) {
|
||||||
|
Write-Host "📦 创建 Python 虚拟环境..." -ForegroundColor Yellow
|
||||||
|
python -m venv backend\.venv
|
||||||
|
& backend\.venv\Scripts\pip install -r backend\requirements.txt -q
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动后端
|
||||||
|
Write-Host "🚀 启动 FastAPI (http://localhost:8000)..." -ForegroundColor Green
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd backend; .\.venv\Scripts\activate; uvicorn app.main:app --reload --port 8000"
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# 启动前端
|
||||||
|
Write-Host "🌐 启动 Next.js (http://localhost:3000)..." -ForegroundColor Green
|
||||||
|
if (-not (Test-Path "frontend\node_modules\next")) {
|
||||||
|
Write-Host "📦 安装前端依赖..." -ForegroundColor Yellow
|
||||||
|
Set-Location frontend
|
||||||
|
npm install
|
||||||
|
Set-Location ..
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Location frontend
|
||||||
|
if (-not (Test-Path ".env.local")) {
|
||||||
|
Copy-Item ".env.local.example" ".env.local"
|
||||||
|
}
|
||||||
|
npm run dev
|
||||||
39
scripts/dev.sh
Normal file
39
scripts/dev.sh
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# NomadFlow 本地开发启动脚本
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🌍 NomadFlow 开发环境启动"
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
if [ ! -d "backend/.venv" ]; then
|
||||||
|
echo "📦 创建 Python 虚拟环境..."
|
||||||
|
python3 -m venv backend/.venv
|
||||||
|
backend/.venv/bin/pip install -r backend/requirements.txt -q
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 启动 FastAPI..."
|
||||||
|
(cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000) &
|
||||||
|
API_PID=$!
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
if [ ! -d "frontend/node_modules/next" ]; then
|
||||||
|
echo "📦 安装前端依赖..."
|
||||||
|
(cd frontend && npm install)
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f frontend/.env.local ] || cp frontend/.env.local.example frontend/.env.local
|
||||||
|
|
||||||
|
echo "🌐 启动 Next.js..."
|
||||||
|
(cd frontend && npm run dev) &
|
||||||
|
WEB_PID=$!
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ 服务已启动:"
|
||||||
|
echo " 前端: http://localhost:3000"
|
||||||
|
echo " API: http://localhost:8000/docs"
|
||||||
|
echo " 按 Ctrl+C 停止"
|
||||||
|
|
||||||
|
trap "kill $API_PID $WEB_PID 2>/dev/null" EXIT
|
||||||
|
wait
|
||||||
Loading…
Reference in New Issue
Block a user