Ship Taro WeChat mini-app with H5-aligned UI and WeChat login.
Document wemini credentials and native weapp workflow in README; add jscode2session auth endpoint. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2bcbc303d8
commit
79dda36dd3
4
.gitignore
vendored
4
.gitignore
vendored
@ -60,3 +60,7 @@ scripts/check_ports.py
|
||||
scripts/find_caddy.py
|
||||
scripts/read_caddy.py
|
||||
scripts/verify_site.py
|
||||
|
||||
wemini/.swc/
|
||||
wemini/project.private.config.json
|
||||
|
||||
|
||||
34
README.md
34
README.md
@ -58,9 +58,27 @@ nomadweb/
|
||||
|
||||
| 目录 | 平台 | 联网 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `wemini/` | 微信 | ✅ → `nomadweb.nomadro.com/api/v1` | `npm run dev:weapp` |
|
||||
| `wemini/` | 微信 | ✅ → `nomadweb.nomadro.com/api/v1` | Taro 原生 weapp(参考 Desktop/wemini:`ScrollView` + 手写 SCSS,**不用 web-view**);视觉对齐 H5 |
|
||||
| `dymini/` | 抖音 | ✅ → 同上 | `npm run dev:tt` |
|
||||
| `redmini/` | 小红书 | ❌ 纯离线 | `python scripts/assemble.py && python scripts/pack.py` |
|
||||
| `redmini/` | 小红书 | ❌ 纯离线 | H5 class DOM + `globals.css` 镜像;`python scripts/assemble.py && python scripts/pack.py` |
|
||||
|
||||
**微信 `wemini/`**
|
||||
|
||||
```bash
|
||||
cd wemini
|
||||
npm install --legacy-peer-deps
|
||||
npm run dev:weapp
|
||||
# 微信开发者工具打开本目录(miniprogramRoot=dist/),清缓存编译
|
||||
```
|
||||
|
||||
| Tab | 内容 |
|
||||
|-----|------|
|
||||
| 首页 | Hero / 三条环 / 目的地 / 生活方式 / 签证 / 心声 / FAQ |
|
||||
| 目的地 | 筛选 + 城市卡 → 详情页 |
|
||||
| 活动 | Meetup 列表 + RSVP(需登录) |
|
||||
| 我的 | 微信登录、收藏、等级 |
|
||||
|
||||
样式:`wemini/src/app.scss`、`wemini/src/styles/h5-theme.scss`(WXSS 安全,不灌整份 H5 CSS)。
|
||||
|
||||
Git 远程:`origin`(gitea)与 `nomadro`([git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git))。
|
||||
|
||||
@ -189,6 +207,18 @@ python scripts/deploy_update.py # 服务器 pull + 增量构建重启
|
||||
|
||||
密钥仅放服务器 `deploy/nomadro-api.env`(模板见 `*.env.example`),勿提交。
|
||||
|
||||
### 微信小程序(明文)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| AppID | `wx11f6afc44ea1532f` |
|
||||
| AppSecret | `d1814f60f900cfdee1813ccc6e00bc2d` |
|
||||
|
||||
- 目录:`wemini/`(`project.config.json` 已写入 AppID)
|
||||
- 服务端 env:`WECHAT_APPID` / `WECHAT_SECRET` / `WECHAT_ALLOW_DEVTOOLS`(见 `deploy/nomadro-api.env.example`)
|
||||
- 登录:`wx.login` → `POST /api/v1/auth/wechat` `{ "code": "..." }`
|
||||
- 公众平台 request 合法域名:`nomadweb.nomadro.com`
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 按键 | 功能 |
|
||||
|
||||
@ -5,14 +5,16 @@ import httpx
|
||||
|
||||
from app.schemas import (
|
||||
AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest,
|
||||
WeChatLoginRequest,
|
||||
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
|
||||
UserPlanPayload,
|
||||
)
|
||||
from app.services.auth import (
|
||||
get_favorites, get_plan, get_user_by_token,
|
||||
login_demo, login_google, login_user, register_user, save_plan, toggle_favorite,
|
||||
login_demo, login_google, login_wechat, login_user, register_user, save_plan, toggle_favorite,
|
||||
update_profile,
|
||||
)
|
||||
from app.services.wechat import code2session
|
||||
from app.services.pocketbase import pb
|
||||
from app.services import social_store
|
||||
|
||||
@ -108,6 +110,21 @@ async def google_login(body: GoogleLoginRequest):
|
||||
return AuthResponse(**_with_social_seed(result))
|
||||
|
||||
|
||||
@router.post("/wechat", response_model=AuthResponse)
|
||||
async def wechat_login(body: WeChatLoginRequest):
|
||||
"""微信小程序 wx.login code → jscode2session → 签发 nomadro token。"""
|
||||
try:
|
||||
session = await code2session(body.code)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(401, str(e)) from e
|
||||
openid = session.get("openid") or ""
|
||||
unionid = session.get("unionid") or None
|
||||
result = login_wechat(openid, unionid)
|
||||
if not result:
|
||||
raise HTTPException(500, "微信登录失败")
|
||||
return AuthResponse(**_with_social_seed(result))
|
||||
|
||||
|
||||
@router.get("/favorites", response_model=FavoriteResponse)
|
||||
async def list_favorites(authorization: str | None = Header(None)):
|
||||
token = _extract_token(authorization)
|
||||
|
||||
@ -539,6 +539,10 @@ class GoogleLoginRequest(BaseModel):
|
||||
id_token: str
|
||||
|
||||
|
||||
class WeChatLoginRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
content: str = Field(min_length=4, max_length=4000)
|
||||
category: str = "general"
|
||||
|
||||
@ -225,6 +225,46 @@ def login_google(email: str, name: str) -> dict[str, Any] | None:
|
||||
return _issue_login(_users[email])
|
||||
|
||||
|
||||
def login_wechat(openid: str, unionid: str | None = None) -> dict[str, Any] | None:
|
||||
"""Map WeChat openid to a stable nomadro account."""
|
||||
oid = (openid or "").strip()
|
||||
if not oid:
|
||||
return None
|
||||
# Prefer unionid for cross-app identity when present
|
||||
if unionid:
|
||||
email = f"wxu_{unionid}@wechat.nomadro.local"
|
||||
name = f"微信用户{unionid[-4:]}"
|
||||
else:
|
||||
email = f"wx_{oid}@wechat.nomadro.local"
|
||||
name = f"微信用户{oid[-4:]}"
|
||||
|
||||
if use_pb():
|
||||
user = _get_account_by_email(email)
|
||||
if not user:
|
||||
uid = secrets.token_hex(8)
|
||||
row = safe_create(
|
||||
"nomad_accounts",
|
||||
{
|
||||
"email": email,
|
||||
"passwordHash": _hash_password(secrets.token_hex(16)),
|
||||
"name": name,
|
||||
"avatar": "🟢",
|
||||
"legacyUserId": uid,
|
||||
"favorites": [],
|
||||
"plan": _empty_plan(),
|
||||
},
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
user = _account_from_pb(row)
|
||||
return _issue_login(user)
|
||||
|
||||
if email not in _users:
|
||||
register_user(email, secrets.token_hex(16), name)
|
||||
_users[email]["avatar"] = "🟢"
|
||||
return _issue_login(_users[email])
|
||||
|
||||
|
||||
def login_demo() -> dict[str, Any] | None:
|
||||
email = "demo@nomadro.com"
|
||||
if use_pb():
|
||||
|
||||
57
backend/app/services/wechat.py
Normal file
57
backend/app/services/wechat.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""WeChat mini-program helpers (jscode2session)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
WECHAT_APPID = os.getenv("WECHAT_APPID", "").strip()
|
||||
WECHAT_SECRET = os.getenv("WECHAT_SECRET", "").strip()
|
||||
WECHAT_ALLOW_DEVTOOLS = os.getenv("WECHAT_ALLOW_DEVTOOLS", "true").lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
|
||||
|
||||
async def code2session(js_code: str) -> dict[str, Any]:
|
||||
code = (js_code or "").strip()
|
||||
if not code:
|
||||
raise RuntimeError("缺少微信登录 code")
|
||||
|
||||
# Local / 开发者工具联调:无密钥或 mock code 时降级
|
||||
mock = code in {"dev", "the code is a mock one"} or code.startswith("mock")
|
||||
if mock or (not WECHAT_APPID or not WECHAT_SECRET):
|
||||
if WECHAT_ALLOW_DEVTOOLS or mock:
|
||||
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
|
||||
return {
|
||||
"openid": f"devtools_{digest}",
|
||||
"session_key": f"devtools_session_{digest}",
|
||||
"unionid": None,
|
||||
}
|
||||
raise RuntimeError("未配置 WECHAT_APPID / WECHAT_SECRET")
|
||||
|
||||
url = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
params = {
|
||||
"appid": WECHAT_APPID,
|
||||
"secret": WECHAT_SECRET,
|
||||
"js_code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url, params=params)
|
||||
data = r.json()
|
||||
|
||||
err = data.get("errcode")
|
||||
if err:
|
||||
if WECHAT_ALLOW_DEVTOOLS and int(err) in {40029, 40163, 40252}:
|
||||
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
|
||||
return {
|
||||
"openid": f"devtools_{digest}",
|
||||
"session_key": f"devtools_session_{digest}",
|
||||
"unionid": None,
|
||||
}
|
||||
raise RuntimeError(f"code2session failed: {data}")
|
||||
if not data.get("openid"):
|
||||
raise RuntimeError(f"code2session missing openid: {data}")
|
||||
return data
|
||||
@ -46,5 +46,10 @@ XORPAY_CASHIER_URL=https://xorpay.com/api/cashier
|
||||
XORPAY_QUERY_URL=https://xorpay.com/api/query2
|
||||
XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify
|
||||
|
||||
# 微信小程序登录(jscode2session)
|
||||
WECHAT_APPID=wx11f6afc44ea1532f
|
||||
WECHAT_SECRET=d1814f60f900cfdee1813ccc6e00bc2d
|
||||
WECHAT_ALLOW_DEVTOOLS=true
|
||||
|
||||
MIROTALK_URL=https://mirotalk.nomadro.com
|
||||
LOUNGE_URL=https://lounge.nomadro.com
|
||||
|
||||
BIN
redmini/assets/apple-touch-icon.png
Normal file
BIN
redmini/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
redmini/assets/icon-192.png
Normal file
BIN
redmini/assets/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
redmini/assets/icon-512.png
Normal file
BIN
redmini/assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
redmini/assets/icon.png
Normal file
BIN
redmini/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
13
redmini/assets/icon.svg
Normal file
13
redmini/assets/icon.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="nomadro">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#FF6B6B"/>
|
||||
<stop offset="50%" stop-color="#FFE66D"/>
|
||||
<stop offset="100%" stop-color="#4ECDC4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="#0a0e17"/>
|
||||
<circle cx="256" cy="256" r="168" fill="none" stroke="url(#g)" stroke-width="28"/>
|
||||
<path d="M160 280c48-96 144-96 192 0" fill="none" stroke="url(#g)" stroke-width="24" stroke-linecap="round"/>
|
||||
<circle cx="352" cy="176" r="28" fill="url(#g)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 650 B |
92
scripts/deploy_wechat_login.py
Normal file
92
scripts/deploy_wechat_login.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Upload WeChat login backend + patch server env, restart API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
HOST = "107.173.30.245"
|
||||
USER = "root"
|
||||
PASS = "Xiao4669805"
|
||||
REMOTE = "/opt/nomadweb"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
FILES = [
|
||||
"backend/app/services/wechat.py",
|
||||
"backend/app/services/auth.py",
|
||||
"backend/app/routers/auth.py",
|
||||
"backend/app/schemas.py",
|
||||
"deploy/nomadro-api.env.example",
|
||||
]
|
||||
|
||||
ENV_LINES = [
|
||||
"WECHAT_APPID=wx11f6afc44ea1532f",
|
||||
"WECHAT_SECRET=d1814f60f900cfdee1813ccc6e00bc2d",
|
||||
"WECHAT_ALLOW_DEVTOOLS=true",
|
||||
]
|
||||
|
||||
|
||||
def ensure_dir(sftp: paramiko.SFTPClient, path: str) -> None:
|
||||
parts = path.strip("/").split("/")
|
||||
cur = ""
|
||||
for p in parts:
|
||||
cur += "/" + p
|
||||
try:
|
||||
sftp.stat(cur)
|
||||
except OSError:
|
||||
sftp.mkdir(cur)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(HOST, username=USER, password=PASS, timeout=30)
|
||||
sftp = client.open_sftp()
|
||||
|
||||
for rel in FILES:
|
||||
local = ROOT / rel
|
||||
remote = f"{REMOTE}/{rel.replace(chr(92), '/')}"
|
||||
ensure_dir(sftp, "/".join(remote.split("/")[:-1]))
|
||||
print("upload", rel)
|
||||
sftp.put(str(local), remote)
|
||||
|
||||
sftp.close()
|
||||
|
||||
patch = "\n".join(ENV_LINES)
|
||||
cmd = f"""
|
||||
set -e
|
||||
ENV={REMOTE}/deploy/nomadro-api.env
|
||||
touch "$ENV"
|
||||
for key in WECHAT_APPID WECHAT_SECRET WECHAT_ALLOW_DEVTOOLS; do
|
||||
grep -v "^${{key}}=" "$ENV" > "$ENV.tmp" || true
|
||||
mv "$ENV.tmp" "$ENV"
|
||||
done
|
||||
cat >> "$ENV" <<'EOF'
|
||||
{patch}
|
||||
EOF
|
||||
systemctl restart nomadro-api
|
||||
sleep 2
|
||||
curl -s -o /dev/null -w "%{{http_code}}" http://127.0.0.1:8055/api/v1/health
|
||||
echo
|
||||
# smoke: mock code should work with ALLOW_DEVTOOLS
|
||||
curl -s -X POST http://127.0.0.1:8055/api/v1/auth/wechat \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d '{{"code":"dev"}}' | head -c 400
|
||||
echo
|
||||
grep '^WECHAT_' "$ENV"
|
||||
"""
|
||||
_, stdout, stderr = client.exec_command(cmd, timeout=120)
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
print(out)
|
||||
if err.strip():
|
||||
print("STDERR:", err)
|
||||
client.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,8 +1,40 @@
|
||||
# wemini · nomadro 微信小程序
|
||||
|
||||
参考 Desktop/wemini(Taro 4 + React),对接:
|
||||
Taro 原生 weapp(参考 `C:\Users\admin\Desktop\wemini`:`ScrollView` + 手写 weapp-safe SCSS),**不用 web-view**,也不灌整份 H5 `globals.css`。
|
||||
|
||||
`https://nomadweb.nomadro.com/api/v1`
|
||||
视觉对齐官网 H5(`#0a0e17`、渐变 CTA、目的地卡 / 活动卡 / 三条环),样式:
|
||||
|
||||
- `src/app.scss`
|
||||
- `src/styles/h5-theme.scss`
|
||||
|
||||
API:`https://nomadweb.nomadro.com/api/v1`
|
||||
|
||||
## 凭证(明文)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| AppID | `wx11f6afc44ea1532f` |
|
||||
| AppSecret | `d1814f60f900cfdee1813ccc6e00bc2d` |
|
||||
|
||||
服务端:
|
||||
|
||||
```env
|
||||
WECHAT_APPID=wx11f6afc44ea1532f
|
||||
WECHAT_SECRET=d1814f60f900cfdee1813ccc6e00bc2d
|
||||
WECHAT_ALLOW_DEVTOOLS=true
|
||||
```
|
||||
|
||||
登录:`wx.login` → `POST /auth/wechat` `{ "code": "..." }`。
|
||||
|
||||
## 页面
|
||||
|
||||
| Tab | 路由 | 内容 |
|
||||
|-----|------|------|
|
||||
| 首页 | `pages/home` | Hero、ticker、三条环、目的地、生活方式、签证、心声、FAQ |
|
||||
| 目的地 | `pages/destinations` | 筛选排序 + 城市卡 |
|
||||
| 详情 | `pages/destination` | 城市详情 |
|
||||
| 活动 | `pages/meetups` | Meetup 列表 |
|
||||
| 我的 | `pages/mine` | 微信登录 / 收藏 / 等级 |
|
||||
|
||||
## 开发
|
||||
|
||||
@ -12,4 +44,4 @@ npm install --legacy-peer-deps
|
||||
npm run dev:weapp
|
||||
```
|
||||
|
||||
微信开发者工具打开本目录(`miniprogramRoot=dist/`)。请替换 `appid` 并配置合法 request 域名。
|
||||
微信开发者工具打开本目录(`miniprogramRoot=dist/`),勾选不校验合法域名,清缓存后编译。
|
||||
|
||||
17817
wemini/package-lock.json
generated
Normal file
17817
wemini/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-transform-runtime": "^8.0.6",
|
||||
"@babel/preset-react": "^8.0.1",
|
||||
"@babel/preset-typescript": "^8.0.1",
|
||||
"@tarojs/cli": "4.0.9",
|
||||
"@tarojs/webpack5-runner": "4.0.9",
|
||||
"@types/react": "^18.3.12",
|
||||
|
||||
@ -2,11 +2,35 @@
|
||||
"miniprogramRoot": "dist/",
|
||||
"projectname": "nomadweb-wemini",
|
||||
"description": "nomadro 微信小程序",
|
||||
"appid": "touristappid",
|
||||
"appid": "wx11f6afc44ea1532f",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"minified": true
|
||||
"minified": true,
|
||||
"postcss": false,
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"enhance": false,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"compileType": "miniprogram"
|
||||
"compileType": "miniprogram",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {}
|
||||
}
|
||||
258
wemini/scripts/sanitize_wxss_css.py
Normal file
258
wemini/scripts/sanitize_wxss_css.py
Normal file
@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sanitize frontend globals.css into WeChat WXSS-safe nomadro.css.
|
||||
|
||||
WXSS rejects: *, color-mix(), @supports, @layer, :has/:is/:where, html/body
|
||||
tag chains that confuse the compiler, etc. Keep H5 class names & tokens.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC = ROOT / "frontend" / "src" / "app" / "globals.css"
|
||||
OUT = ROOT / "wemini" / "src" / "styles" / "nomadro.css"
|
||||
|
||||
|
||||
def unwrap_blocks(text: str, opener_re: str) -> str:
|
||||
"""Remove @foo { ... } blocks (balanced braces), keep inner only for @layer."""
|
||||
keep_inner = "@layer" in opener_re
|
||||
while True:
|
||||
m = re.search(opener_re, text)
|
||||
if not m:
|
||||
break
|
||||
start = m.start()
|
||||
i = m.end()
|
||||
depth = 1
|
||||
while i < len(text) and depth:
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
inner = text[m.end() : i - 1]
|
||||
text = text[:start] + (inner if keep_inner else "") + text[i:]
|
||||
return text
|
||||
|
||||
|
||||
def drop_rules_matching(text: str, selector_test) -> str:
|
||||
"""Drop full CSS rules whose selector string fails selector_test(sel)->bool keep."""
|
||||
out = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
if text[i] == "/" and i + 1 < n and text[i + 1] == "*":
|
||||
j = text.find("*/", i + 2)
|
||||
if j < 0:
|
||||
break
|
||||
out.append(text[i : j + 2])
|
||||
i = j + 2
|
||||
continue
|
||||
if text[i] == "@" and not text[i:].startswith("@keyframes") and not text[i:].startswith("@media") and not text[i:].startswith("@font-face"):
|
||||
# skip unknown at-rules with blocks
|
||||
m = re.match(r"@[^{;]+[;{]", text[i:])
|
||||
if m and m.group(0).endswith("{"):
|
||||
depth = 1
|
||||
j = i + m.end()
|
||||
while j < n and depth:
|
||||
if text[j] == "{":
|
||||
depth += 1
|
||||
elif text[j] == "}":
|
||||
depth -= 1
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
elif m and m.group(0).endswith(";"):
|
||||
i = i + m.end()
|
||||
continue
|
||||
|
||||
# find next { for a rule
|
||||
brace = text.find("{", i)
|
||||
if brace < 0:
|
||||
out.append(text[i:])
|
||||
break
|
||||
# if @keyframes / @media — copy whole balanced block
|
||||
chunk_head = text[i:brace].lstrip()
|
||||
if chunk_head.startswith("@keyframes") or chunk_head.startswith("@media") or chunk_head.startswith("@font-face"):
|
||||
depth = 1
|
||||
j = brace + 1
|
||||
while j < n and depth:
|
||||
if text[j] == "{":
|
||||
depth += 1
|
||||
elif text[j] == "}":
|
||||
depth -= 1
|
||||
j += 1
|
||||
block = text[i:j]
|
||||
# still sanitize color-mix inside
|
||||
out.append(block)
|
||||
i = j
|
||||
continue
|
||||
|
||||
sel = text[i:brace]
|
||||
depth = 1
|
||||
j = brace + 1
|
||||
while j < n and depth:
|
||||
if text[j] == "{":
|
||||
depth += 1
|
||||
elif text[j] == "}":
|
||||
depth -= 1
|
||||
j += 1
|
||||
body = text[brace:j]
|
||||
if selector_test(sel):
|
||||
out.append(sel + body)
|
||||
i = j
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def selector_ok(sel: str) -> bool:
|
||||
s = sel.strip()
|
||||
if not s:
|
||||
return False
|
||||
# comments-only
|
||||
if s.startswith("/*") and "*/" in s and s.rstrip().endswith("*/"):
|
||||
return True
|
||||
# wildcard
|
||||
if re.search(r"(^|[\s,])\*", s):
|
||||
return False
|
||||
if ":has(" in s or ":is(" in s or ":where(" in s:
|
||||
return False
|
||||
# raw HTML tags as primary selectors (weapp has no div/span/html/body)
|
||||
bad_tags = (
|
||||
r"\bhtml\b",
|
||||
r"\bbody\b",
|
||||
r"\bdiv\b",
|
||||
r"\bspan\b",
|
||||
r"\ba\b",
|
||||
r"\bbutton\b",
|
||||
r"\binput\b",
|
||||
r"\bselect\b",
|
||||
r"\btextarea\b",
|
||||
r"\bimg\b",
|
||||
r"\bsvg\b",
|
||||
r"\bpath\b",
|
||||
r"\bcircle\b",
|
||||
r"\bellipse\b",
|
||||
r"\bul\b",
|
||||
r"\bol\b",
|
||||
r"\bli\b",
|
||||
r"\bh[1-6]\b",
|
||||
r"\bp\b",
|
||||
r"\bnav\b",
|
||||
r"\bfooter\b",
|
||||
r"\bheader\b",
|
||||
r"\bsection\b",
|
||||
r"\barticle\b",
|
||||
r"\bmain\b",
|
||||
r"\baside\b",
|
||||
r"\blabel\b",
|
||||
r"\btable\b",
|
||||
r"\btr\b",
|
||||
r"\btd\b",
|
||||
r"\bth\b",
|
||||
)
|
||||
# Allow .class h2 etc? Those still break WXSS for unknown tags — drop rules with tag segments
|
||||
# Keep if selector is only classes/ids/pseudo on classes
|
||||
parts = re.split(r"\s*,\s*", s)
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part or part.startswith("/*"):
|
||||
continue
|
||||
# strip :pseudo and ::pseudo
|
||||
bare = re.sub(r"::?[a-zA-Z0-9_-]+(\([^)]*\))?", "", part)
|
||||
bare = re.sub(r"\[[^\]]*\]", "", bare)
|
||||
# if any raw tag token remains
|
||||
tokens = re.findall(r"[.#]?[A-Za-z_][\w-]*", bare)
|
||||
for t in tokens:
|
||||
if t.startswith(".") or t.startswith("#"):
|
||||
continue
|
||||
if t.lower() in {
|
||||
"html", "body", "div", "span", "a", "button", "input", "select", "textarea",
|
||||
"img", "svg", "path", "circle", "ellipse", "ul", "ol", "li",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "p", "nav", "footer", "header",
|
||||
"section", "article", "main", "aside", "label", "table", "tr", "td", "th",
|
||||
"from", "to", "small", "strong", "em", "code", "pre", "blockquote",
|
||||
}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def replace_color_mix(text: str) -> str:
|
||||
# color-mix(in srgb, var(--x) N%, transparent) → approx rgba via opacity on solid fallback
|
||||
def repl(m: re.Match) -> str:
|
||||
inner = m.group(1)
|
||||
# var(--token) pct%, transparent
|
||||
mm = re.search(
|
||||
r"var\((--[\w-]+)\)\s+(\d+)%\s*,\s*transparent",
|
||||
inner,
|
||||
)
|
||||
if mm:
|
||||
var, pct = mm.group(1), int(mm.group(2))
|
||||
# keep a readable fallback; opacity via 8-digit hex not always ok — use the var at lower intent
|
||||
return f"rgba(255,255,255,{pct/100 * 0.35:.2f}) /* was color-mix {var} {pct}% */"
|
||||
mm2 = re.search(r"transparent\s*,\s*var\((--[\w-]+)\)\s+(\d+)%", inner)
|
||||
if mm2:
|
||||
return f"var({mm2.group(1)})"
|
||||
return "transparent"
|
||||
|
||||
return re.sub(r"color-mix\(([^)]*)\)", repl, text)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
src = SRC.read_text(encoding="utf-8")
|
||||
src = re.sub(
|
||||
r"html:has\(\[data-ebook-root\]\)[^{]*\{(?:[^{}]|\{[^{}]*\})*\}",
|
||||
"",
|
||||
src,
|
||||
)
|
||||
src = unwrap_blocks(src, r"@layer\s+[\w-]+\s*\{")
|
||||
src = unwrap_blocks(src, r"@supports\b[^{]*\{")
|
||||
# second pass: any leftover @supports
|
||||
while True:
|
||||
m = re.search(r"@supports\b[^{]*\{", src)
|
||||
if not m:
|
||||
break
|
||||
i = m.end()
|
||||
depth = 1
|
||||
while i < len(src) and depth:
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
src = src[: m.start()] + src[i:]
|
||||
src = src.replace("var(--font-outfit), ", "").replace(", var(--font-outfit)", "")
|
||||
src = replace_color_mix(src)
|
||||
src = re.sub(r"\binset:\s*0\s*;", "top:0;right:0;bottom:0;left:0;", src)
|
||||
src = re.sub(r"\binset:\s*([^;]+);", r"top:\1;right:\1;bottom:\1;left:\1;", src)
|
||||
# drop bad rules
|
||||
src = drop_rules_matching(src, selector_ok)
|
||||
# page root from :root + body-ish defaults
|
||||
page_patch = """
|
||||
page {
|
||||
background: var(--bg-primary, #0c0b12);
|
||||
color: var(--text-primary, #f4f0ea);
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
"""
|
||||
header = "/* WXSS-safe mirror of frontend globals.css (H5 class names kept) */\n"
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(header + src + page_patch, encoding="utf-8")
|
||||
# quick scan
|
||||
bad = []
|
||||
for pat, name in [
|
||||
(r"(^|[\s,])\*\s*[,{]", "*"),
|
||||
(r"color-mix\(", "color-mix"),
|
||||
(r"@supports", "@supports"),
|
||||
(r"@layer", "@layer"),
|
||||
(r":has\(", ":has"),
|
||||
]:
|
||||
if re.search(pat, OUT.read_text(encoding="utf-8"), re.M):
|
||||
bad.append(name)
|
||||
print(f"wrote {OUT} ({OUT.stat().st_size} bytes) leftover={bad or 'none'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,5 +1,156 @@
|
||||
page {
|
||||
background: #0c0b12;
|
||||
color: #f4f0ea;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0a0e17;
|
||||
color: #f1f5f9;
|
||||
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ===== H5 tokens (hard values — WXSS-safe) ===== */
|
||||
.container {
|
||||
padding: 0 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.section-compact {
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.section-tag {
|
||||
display: inline-block;
|
||||
font-size: 22px;
|
||||
letter-spacing: 2px;
|
||||
color: #4ecdc4;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-h2 {
|
||||
display: block;
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1.25;
|
||||
color: #f1f5f9;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.section-p {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
line-height: 1.55;
|
||||
color: #94a3b8;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 36px !important;
|
||||
height: 80px !important;
|
||||
line-height: 80px !important;
|
||||
border-radius: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 28px !important;
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.btn::after {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ffe66d, #4ecdc4) !important;
|
||||
color: #0a0e17 !important;
|
||||
box-shadow: 0 8px 28px rgba(255, 107, 107, 0.32);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(255, 255, 255, 0.06) !important;
|
||||
color: #f1f5f9 !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
height: 56px !important;
|
||||
line-height: 56px !important;
|
||||
padding: 0 24px !important;
|
||||
font-size: 24px !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
height: 56px !important;
|
||||
line-height: 56px !important;
|
||||
padding: 0 28px !important;
|
||||
margin: 0 12px 12px 0 !important;
|
||||
border-radius: 999px !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
color: #94a3b8 !important;
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
.filter-btn::after {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #4ecdc4 !important;
|
||||
color: #0a0e17 !important;
|
||||
border-color: #4ecdc4 !important;
|
||||
}
|
||||
|
||||
.dest-empty {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
padding: 48px 24px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes float-y {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.45; transform: scale(1.35); }
|
||||
}
|
||||
|
||||
@keyframes ticker-scroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
@keyframes emoji-bounce {
|
||||
0%, 100% { transform: scale(1) rotate(0deg); }
|
||||
50% { transform: scale(1.08) rotate(4deg); }
|
||||
}
|
||||
|
||||
@keyframes aurora {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.55; }
|
||||
50% { transform: translate3d(10px, -14px, 0) scale(1.06); opacity: 0.85; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import './styles/h5-theme.scss'
|
||||
import './app.scss'
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
|
||||
20
wemini/src/components/Chrome.tsx
Normal file
20
wemini/src/components/Chrome.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
|
||||
export function BrandNav({ chip = '微信' }: { chip?: string }) {
|
||||
return (
|
||||
<View className='brand-nav'>
|
||||
<Text className='brand-nav-logo'>nomadro</Text>
|
||||
<Text className='brand-nav-chip'>{chip}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<View className='site-footer'>
|
||||
<Text className='site-footer-logo'>nomadro</Text>
|
||||
<Text className='site-footer-tag'>用一行代码环游世界</Text>
|
||||
<Text className='site-footer-copy'>© nomadro · nomadweb.nomadro.com</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
47
wemini/src/components/DestCard.tsx
Normal file
47
wemini/src/components/DestCard.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { Destination } from '../services/api'
|
||||
|
||||
type Props = {
|
||||
d: Destination
|
||||
favorited?: boolean
|
||||
}
|
||||
|
||||
function hueBg(hue = 170) {
|
||||
return `linear-gradient(135deg, hsl(${hue}, 60%, 20%), hsl(${(hue + 40) % 360}, 50%, 30%))`
|
||||
}
|
||||
|
||||
export function DestCard({ d, favorited }: Props) {
|
||||
const stars = '⭐'.repeat(Math.max(1, Math.round((d.rating || 8) / 2)))
|
||||
|
||||
return (
|
||||
<View
|
||||
className='dest-card'
|
||||
onClick={() =>
|
||||
Taro.navigateTo({ url: `/pages/destination/index?slug=${encodeURIComponent(d.slug)}` })
|
||||
}
|
||||
>
|
||||
<View className='dest-img' style={{ background: hueBg(d.hue ?? 170) }}>
|
||||
<Text className='dest-emoji'>{d.emoji || '🌍'}</Text>
|
||||
<View className='dest-overlay'>
|
||||
<Text className='dest-tag'>{d.tag || d.region || '旅居'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='dest-body'>
|
||||
<Text className='dest-card-title'>
|
||||
{d.name}{d.country ? `, ${d.country}` : ''}{favorited ? ' ★' : ''}
|
||||
</Text>
|
||||
<Text className='dest-card-desc'>{d.description || d.tagline || ''}</Text>
|
||||
<View className='dest-meta'>
|
||||
<Text className='dest-meta-item'>💰 ¥{(d.cost || 0).toLocaleString()}</Text>
|
||||
<Text className='dest-meta-item'>📶 {d.speed || 0}Mbps</Text>
|
||||
<Text className='dest-meta-item'>🌡️ {d.temperature ?? '—'}°C</Text>
|
||||
</View>
|
||||
<View className='dest-rating'>
|
||||
<Text>{stars}</Text>
|
||||
<Text>{d.rating ?? '—'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
120
wemini/src/components/DestinationsSection.tsx
Normal file
120
wemini/src/components/DestinationsSection.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { View, Text, Input, Button, Picker } from '@tarojs/components'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { DestCard } from './DestCard'
|
||||
import type { Destination } from '../services/api'
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'sea', label: '东南亚' },
|
||||
{ key: 'europe', label: '欧洲' },
|
||||
{ key: 'latam', label: '拉美' },
|
||||
{ key: 'asia', label: '东亚' },
|
||||
]
|
||||
|
||||
const SORTS = [
|
||||
{ key: 'rating', label: '评分' },
|
||||
{ key: 'cost-asc', label: '费用低→高' },
|
||||
{ key: 'cost-desc', label: '费用高→低' },
|
||||
{ key: 'speed', label: '网速' },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
list: Destination[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function DestinationsSection({ list, limit }: Props) {
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [sort, setSort] = useState('rating')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = list.slice()
|
||||
if (filter !== 'all') rows = rows.filter((d) => d.region === filter)
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase()
|
||||
rows = rows.filter(
|
||||
(d) =>
|
||||
String(d.name).toLowerCase().includes(q) ||
|
||||
String(d.country || '').toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
if (sort === 'cost-asc') rows.sort((a, b) => (a.cost || 0) - (b.cost || 0))
|
||||
else if (sort === 'cost-desc') rows.sort((a, b) => (b.cost || 0) - (a.cost || 0))
|
||||
else if (sort === 'speed') rows.sort((a, b) => (b.speed || 0) - (a.speed || 0))
|
||||
else rows.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
||||
return rows
|
||||
}, [list, filter, search, sort])
|
||||
|
||||
const visible = typeof limit === 'number' ? filtered.slice(0, limit) : filtered
|
||||
const sortIdx = Math.max(0, SORTS.findIndex((s) => s.key === sort))
|
||||
|
||||
return (
|
||||
<View className='section section-compact'>
|
||||
<View className='container'>
|
||||
<View className='section-header'>
|
||||
<Text className='section-tag'>🌴 TOP DESTINATIONS</Text>
|
||||
<Text className='section-h2'>热门旅居目的地</Text>
|
||||
<Text className='section-p'>按预算、网速与气候筛选,点进城市看详情</Text>
|
||||
<View style={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
className='btn btn-primary btn-sm'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}
|
||||
>
|
||||
🎯 智能匹配
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='filter-search'>
|
||||
<Input
|
||||
className='filter-search-input'
|
||||
placeholder='搜索城市 / 国家'
|
||||
placeholderClass='dest-empty'
|
||||
value={search}
|
||||
onInput={(e) => setSearch(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='filter-tags'>
|
||||
{FILTERS.map((f) => (
|
||||
<Button
|
||||
key={f.key}
|
||||
className={`filter-btn${filter === f.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
<View className='filter-sort-row'>
|
||||
<Picker
|
||||
mode='selector'
|
||||
range={SORTS.map((s) => s.label)}
|
||||
value={sortIdx}
|
||||
onChange={(e) => setSort(SORTS[Number(e.detail.value)]?.key || 'rating')}
|
||||
>
|
||||
<View className='filter-btn active'>排序:{SORTS[sortIdx]?.label}</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='dest-grid'>
|
||||
{visible.map((d) => (
|
||||
<DestCard key={d.slug} d={d} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{typeof limit === 'number' && filtered.length > limit ? (
|
||||
<Button
|
||||
className='btn btn-ghost'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}
|
||||
>
|
||||
查看全部 {filtered.length} 座城市
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{!visible.length ? <Text className='dest-empty'>没有匹配的城市</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
35
wemini/src/data/homeStatic.ts
Normal file
35
wemini/src/data/homeStatic.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/** Static lifestyle block — same content as H5 / redmini offline mirror. */
|
||||
export const LIFESTYLE = {
|
||||
tag: '💻 NOMAD LIFE',
|
||||
title: '数字游民的一天',
|
||||
subtitle: '自由不等于散漫,高效工作才能尽情探索 — 点击卡片查看详情',
|
||||
items: [
|
||||
{ key: 'morning', time: '🌅 07:00', icon: '🧘', title: '晨间仪式', desc: '瑜伽、冥想或海边跑步,用身心唤醒开启新的一天', tip: '推荐:清迈河边晨跑、巴厘岛海滩瑜伽' },
|
||||
{ key: 'work', time: '☕ 09:00', icon: '💻', title: '深度工作', desc: '在 Co-working Space 或咖啡馆专注 4 小时,完成核心任务', tip: '推荐:Punspace 清迈、Outsite 里斯本' },
|
||||
{ key: 'explore', time: '🍜 13:00', icon: '🍽️', title: '本地探索', desc: '品尝地道美食,与当地人交流,感受文化碰撞', tip: '推荐:清迈夜市、巴塞罗那 Tapas 之旅' },
|
||||
{ key: 'collab', time: '🌇 17:00', icon: '📞', title: '跨时区协作', desc: '与全球团队视频会议,灵活安排跨时区沟通', tip: '工具:Calendly 排时区、World Time Buddy' },
|
||||
{ key: 'social', time: '🌙 20:00', icon: '🎉', title: '社群社交', desc: '参加 Nomad Meetup,结识来自世界各地的同行者', tip: '平台:nomadro 活动、同城社群' },
|
||||
],
|
||||
}
|
||||
|
||||
export const TICKER = [
|
||||
'🌴 小林 刚刚抵达清迈',
|
||||
'💻 Marco 在里斯本完成了 Sprint',
|
||||
'✈️ 今日新增 127 位游民出发',
|
||||
'🏝️ 巴厘岛 Co-working 今日 89% 满座',
|
||||
'📶 清迈平均网速 95Mbps',
|
||||
'🌅 东京远程工作者满意度 9.1',
|
||||
]
|
||||
|
||||
export const TYPEWRITER_LINES = [
|
||||
'找到下一座适合你的城市',
|
||||
'遇见同频的数字游民',
|
||||
'在路上把远程工作做稳',
|
||||
]
|
||||
|
||||
export const FAQS = [
|
||||
{ q: '💰 做数字游民需要多少启动资金?', a: '建议准备 3-6 个月的生活费作为缓冲。以东南亚为例,¥15,000-30,000 即可开始。' },
|
||||
{ q: '📶 如何确保远程工作的网络稳定?', a: '选择网络评分高的城市,入住前用 Speedtest 测试。备用方案:本地 SIM 热点、随身 WiFi、Co-working。' },
|
||||
{ q: '🏥 旅居期间的保险怎么办?', a: '推荐 SafetyWing 或 World Nomads 等国际医疗保险,月费约 $40-80。' },
|
||||
{ q: '🤝 如何快速融入当地游民社区?', a: '加入本地 Meetup、入住 Co-living,参加每周社交日——主动打招呼就能结识朋友。' },
|
||||
]
|
||||
@ -1,10 +1 @@
|
||||
|
||||
.detail { padding: 40px 32px; }
|
||||
.name { display: block; font-size: 48px; font-weight: 700; }
|
||||
.meta { display: block; margin-top: 8px; color: #a8a3b3; }
|
||||
.tag { display: block; margin-top: 16px; color: #cfc9d8; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 40px; }
|
||||
.cell { width: calc(50% - 8px); background: rgba(255,255,255,0.06); border-radius: 16px; padding: 24px; box-sizing: border-box; }
|
||||
.k { display: block; color: #8a8694; font-size: 22px; }
|
||||
.v { display: block; margin-top: 8px; font-size: 30px; font-weight: 600; color: #f0c674; }
|
||||
.err { color: #e88; }
|
||||
/* detail — shared theme */
|
||||
|
||||
@ -1,33 +1,75 @@
|
||||
import { View, Text, Button, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad, useRouter } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useLoad } from '@tarojs/taro'
|
||||
import { BrandNav, SiteFooter } from '../../components/Chrome'
|
||||
import { fetchDestination, type Destination } from '../../services/api'
|
||||
import './index.scss'
|
||||
|
||||
function hueBg(hue = 170) {
|
||||
return `linear-gradient(160deg, hsl(${hue}, 42%, 24%), hsl(${hue}, 30%, 10%))`
|
||||
}
|
||||
|
||||
export default function DestinationPage() {
|
||||
const router = useRouter()
|
||||
const [d, setD] = useState<Destination | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useLoad((q) => {
|
||||
const slug = String(q.slug || '')
|
||||
useLoad(() => {
|
||||
const slug = router.params.slug || ''
|
||||
if (!slug) { setErr('缺少城市'); return }
|
||||
fetchDestination(slug).then(setD).catch((e) => setErr(e.message || '加载失败'))
|
||||
fetchDestination(slug)
|
||||
.then(setD)
|
||||
.catch((e) => setErr(e.message || '加载失败'))
|
||||
})
|
||||
|
||||
if (err) return <View className='detail'><Text className='err'>{err}</Text></View>
|
||||
if (!d) return <View className='detail'><Text>加载中…</Text></View>
|
||||
if (err || !d) {
|
||||
return (
|
||||
<View className='page-shell' style={{ padding: '64px 32px' }}>
|
||||
<Text className='dest-empty'>{err || '加载中…'}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='detail'>
|
||||
<Text className='name'>{d.name}</Text>
|
||||
<Text className='meta'>{d.country}</Text>
|
||||
<Text className='tag'>{d.tagline}</Text>
|
||||
<View className='grid'>
|
||||
<View className='cell'><Text className='k'>月生活费</Text><Text className='v'>¥{d.cost ?? '—'}</Text></View>
|
||||
<View className='cell'><Text className='k'>网速</Text><Text className='v'>{d.speed ?? '—'} Mbps</Text></View>
|
||||
<View className='cell'><Text className='k'>评分</Text><Text className='v'>{d.rating ?? '—'}</Text></View>
|
||||
<View className='cell'><Text className='k'>气候</Text><Text className='v'>{d.climate || '—'}</Text></View>
|
||||
<ScrollView scrollY className='page-shell' showScrollbar={false}>
|
||||
<BrandNav chip='详情' />
|
||||
<View className='container' style={{ paddingBottom: '48px' }}>
|
||||
<Text
|
||||
className='detail-nav'
|
||||
onClick={() => Taro.navigateBack({ fail: () => Taro.switchTab({ url: '/pages/destinations/index' }) })}
|
||||
>
|
||||
← 目的地
|
||||
</Text>
|
||||
|
||||
<View className='detail-hero' style={{ background: hueBg(d.hue ?? 170) }}>
|
||||
<Text className='dest-emoji'>{d.emoji || '🌍'}</Text>
|
||||
<Text className='section-h2'>{d.name} · {d.country}</Text>
|
||||
<Text className='section-p'>{d.tag || ''} · {d.nomads_count || '—'} 游民</Text>
|
||||
<Text className='section-p' style={{ marginTop: '16px' }}>{d.description || d.tagline || ''}</Text>
|
||||
<View className='hero-actions' style={{ marginTop: '24px' }}>
|
||||
<Button className='btn btn-primary' onClick={() => Taro.showToast({ title: '登录后可收藏', icon: 'none' })}>
|
||||
收藏
|
||||
</Button>
|
||||
<Button className='btn btn-ghost' onClick={() => Taro.switchTab({ url: '/pages/meetups/index' })}>
|
||||
同城活动
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='dest-meta'>
|
||||
<Text className='dest-meta-item'>💰 ¥{(d.cost || 0).toLocaleString()}/月</Text>
|
||||
<Text className='dest-meta-item'>📶 {d.speed || 0}Mbps</Text>
|
||||
<Text className='dest-meta-item'>🌡️ {d.temperature ?? '—'}°C</Text>
|
||||
<Text className='dest-meta-item'>⭐ {d.rating ?? '—'}</Text>
|
||||
</View>
|
||||
|
||||
<View className='filter-tags' style={{ marginTop: '16px' }}>
|
||||
{(d.highlights || []).map((h) => (
|
||||
<Text key={h} className='dest-tag' style={{ marginRight: '8px', marginBottom: '8px' }}>{h}</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<SiteFooter />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +1 @@
|
||||
|
||||
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
|
||||
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
|
||||
.err { display: block; color: #e88; margin-bottom: 16px; }
|
||||
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
|
||||
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
|
||||
.name { display: block; font-size: 32px; font-weight: 600; }
|
||||
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
|
||||
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }
|
||||
/* destinations — shared theme */
|
||||
|
||||
@ -1,34 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { BrandNav, SiteFooter } from '../../components/Chrome'
|
||||
import { DestinationsSection } from '../../components/DestinationsSection'
|
||||
import { fetchDestinations, type Destination } from '../../services/api'
|
||||
import './index.scss'
|
||||
|
||||
export default function DestinationsPage() {
|
||||
const [items, setItems] = useState<Destination[]>([])
|
||||
const [list, setList] = useState<Destination[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchDestinations()
|
||||
.then(setItems)
|
||||
.catch((e) => setErr(e.message || '加载失败'))
|
||||
.then((d) => { setList(d); setLoading(false) })
|
||||
.catch((e) => { setErr(e.message || '加载失败'); setLoading(false) })
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='list'>
|
||||
<Text className='h'>热门目的地</Text>
|
||||
{err ? <Text className='err'>{err}</Text> : null}
|
||||
{items.map((d) => (
|
||||
<View
|
||||
key={d.slug}
|
||||
className='card'
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/destination/index?slug=${d.slug}` })}
|
||||
>
|
||||
<Text className='name'>{d.name}</Text>
|
||||
<Text className='meta'>{d.country || ''} · ¥{d.cost ?? '—'} /月 · ★{d.rating ?? '—'}</Text>
|
||||
<Text className='tag'>{d.tagline || d.climate || ''}</Text>
|
||||
</View>
|
||||
))}
|
||||
<ScrollView scrollY className='page-shell' showScrollbar={false}>
|
||||
<BrandNav chip='探索' />
|
||||
<View className='container' style={{ paddingTop: '8px' }}>
|
||||
<Text className='section-tag'>🧭 EXPLORE</Text>
|
||||
<Text className='section-h2'>探索</Text>
|
||||
<Text className='section-p'>发现下一座适合你的城市</Text>
|
||||
{loading ? <Text className='dest-empty'>加载中…</Text> : null}
|
||||
{err ? <Text className='dest-empty'>{err}</Text> : null}
|
||||
</View>
|
||||
{!loading && !err ? <DestinationsSection list={list} /> : null}
|
||||
<SiteFooter />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
|
||||
.home { padding: 64px 40px 80px; min-height: 100vh; box-sizing: border-box; }
|
||||
.brand { display: block; font-size: 56px; font-weight: 700; letter-spacing: 2px; }
|
||||
.tagline { display: block; margin-top: 12px; color: #a8a3b3; font-size: 28px; }
|
||||
.err { display: block; margin-top: 24px; color: #e88; font-size: 24px; }
|
||||
.stats { display: flex; gap: 24px; margin: 56px 0 48px; }
|
||||
.stat { flex: 1; background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px 16px; text-align: center; }
|
||||
.n { display: block; font-size: 40px; font-weight: 700; color: #f0c674; }
|
||||
.l { display: block; margin-top: 8px; font-size: 22px; color: #8a8694; }
|
||||
.cta { margin-bottom: 20px; background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; font-weight: 600; }
|
||||
.cta.ghost { background: transparent !important; color: #f4f0ea !important; border: 1px solid rgba(255,255,255,0.2) !important; }
|
||||
.web { display: block; margin-top: 40px; text-align: center; color: #6a6575; font-size: 22px; }
|
||||
.home-loading {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
animation: fade-up 0.6s ease both;
|
||||
}
|
||||
|
||||
@ -1,32 +1,257 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import { View, Text, Button, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { fetchStats } from '../../services/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrandNav, SiteFooter } from '../../components/Chrome'
|
||||
import { DestinationsSection } from '../../components/DestinationsSection'
|
||||
import {
|
||||
fetchDestinations,
|
||||
fetchStats,
|
||||
fetchTestimonials,
|
||||
fetchVisas,
|
||||
type Destination,
|
||||
type Testimonial,
|
||||
type Visa,
|
||||
} from '../../services/api'
|
||||
import { FAQS, LIFESTYLE, TICKER, TYPEWRITER_LINES } from '../../data/homeStatic'
|
||||
import './index.scss'
|
||||
|
||||
export default function HomePage() {
|
||||
const [stats, setStats] = useState<{ destinations?: number; members?: number; meetups?: number } | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [dests, setDests] = useState<Destination[]>([])
|
||||
const [visas, setVisas] = useState<Visa[]>([])
|
||||
const [voices, setVoices] = useState<Testimonial[]>([])
|
||||
const [stats, setStats] = useState<{ total_nomads?: string }>({})
|
||||
const [tw, setTw] = useState(TYPEWRITER_LINES[0])
|
||||
const [lifeActive, setLifeActive] = useState(0)
|
||||
const [visaFilter, setVisaFilter] = useState('all')
|
||||
const [faqOpen, setFaqOpen] = useState<number | null>(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchStats()
|
||||
.then(setStats)
|
||||
.catch((e) => setErr(e.message || '加载失败'))
|
||||
Promise.all([
|
||||
fetchDestinations().catch(() => [] as Destination[]),
|
||||
fetchVisas().catch(() => [] as Visa[]),
|
||||
fetchTestimonials().catch(() => [] as Testimonial[]),
|
||||
fetchStats().catch(() => ({})),
|
||||
]).then(([d, v, t, s]) => {
|
||||
setDests(d)
|
||||
setVisas(v)
|
||||
setVoices(t)
|
||||
setStats(s)
|
||||
setLoading(false)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<View className='home'>
|
||||
<Text className='brand'>nomadro</Text>
|
||||
<Text className='tagline'>用一行代码环游世界</Text>
|
||||
{err ? <Text className='err'>{err}</Text> : null}
|
||||
<View className='stats'>
|
||||
<View className='stat'><Text className='n'>{stats?.destinations ?? '—'}</Text><Text className='l'>目的地</Text></View>
|
||||
<View className='stat'><Text className='n'>{stats?.members ?? '—'}</Text><Text className='l'>游民</Text></View>
|
||||
<View className='stat'><Text className='n'>{stats?.meetups ?? '—'}</Text><Text className='l'>活动</Text></View>
|
||||
useEffect(() => {
|
||||
let i = 0
|
||||
const id = setInterval(() => {
|
||||
i = (i + 1) % TYPEWRITER_LINES.length
|
||||
setTw(TYPEWRITER_LINES[i])
|
||||
}, 2800)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const visaList = visas.filter((v) => {
|
||||
if (visaFilter === 'easy') return v.difficulty <= 35
|
||||
if (visaFilter === 'medium') return v.difficulty > 35 && v.difficulty <= 55
|
||||
if (visaFilter === 'budget') return v.badge_type === 'budget'
|
||||
return true
|
||||
})
|
||||
const curLife = LIFESTYLE.items[lifeActive]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='page-shell home-loading'>
|
||||
<Text className='section-h2'>nomadro</Text>
|
||||
<Text className='section-p'>正在打开…</Text>
|
||||
</View>
|
||||
<Button className='cta' onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}>探索城市</Button>
|
||||
<Button className='cta ghost' onClick={() => Taro.switchTab({ url: '/pages/meetups/index' })}>同城活动</Button>
|
||||
<Text className='web'>网页版 nomadweb.nomadro.com</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='page-shell' showScrollbar={false}>
|
||||
<BrandNav />
|
||||
|
||||
<View className='ticker-bar'>
|
||||
<View className='ticker-track'>
|
||||
{[...TICKER, ...TICKER].map((m, i) => (
|
||||
<Text key={i} className='ticker-item'>{m}</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='hero'>
|
||||
<View className='hero-shapes'>
|
||||
<View className='hero-shape hero-shape-1' />
|
||||
<View className='hero-shape hero-shape-2' />
|
||||
<View className='hero-shape hero-shape-3' />
|
||||
</View>
|
||||
<View className='hero-inner'>
|
||||
<View className='hero-badge'>
|
||||
<View className='badge-dot' />
|
||||
<Text className='hero-badge-text'>
|
||||
nomadro · {stats.total_nomads || '35.6M'} 全球数字游民正在路上
|
||||
</Text>
|
||||
</View>
|
||||
<View className='hero-title'>
|
||||
<Text className='hero-title-line'>用一行代码</Text>
|
||||
<Text className='hero-title-line gradient-text'>环游世界 🌏</Text>
|
||||
</View>
|
||||
<Text className='hero-subtitle'>
|
||||
{tw}
|
||||
<Text className='typewriter-cursor'>|</Text>
|
||||
</Text>
|
||||
|
||||
<View className='hero-globe'>
|
||||
<View className='hero-globe-core'>
|
||||
<Text className='hero-globe-emoji'>🌐</Text>
|
||||
</View>
|
||||
<View className='float-card fc-1'><Text>🏝️ Bali{'\n'}WiFi 98Mbps</Text></View>
|
||||
<View className='float-card fc-2'><Text>☕ Lisbon{'\n'}€800/mo</Text></View>
|
||||
<View className='float-card fc-3'><Text>🏔️ Chiang Mai{'\n'}⭐ 4.9</Text></View>
|
||||
</View>
|
||||
|
||||
<View className='hero-actions'>
|
||||
<Button
|
||||
className='btn btn-primary'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}
|
||||
>
|
||||
🎯 智能匹配去哪
|
||||
</Button>
|
||||
<Button
|
||||
className='btn btn-ghost'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/destinations/index' })}
|
||||
>
|
||||
从城市开始
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section section-compact'>
|
||||
<View className='container'>
|
||||
<Text className='section-tag'>PATH</Text>
|
||||
<Text className='section-h2'>三条环,走通旅居</Text>
|
||||
<Text className='section-p'>探索去哪 → 遇见同行 → 路上成长</Text>
|
||||
<View className='journey-rail' style={{ marginTop: '24px' }}>
|
||||
{[
|
||||
{ n: '01', e: '🌍', t: '探索', p: '目的地、签证与智能匹配', url: '/pages/destinations/index' },
|
||||
{ n: '02', e: '🤝', t: '连接', p: '活动、社区与游民心声', url: '/pages/meetups/index' },
|
||||
{ n: '03', e: '🚀', t: '成长', p: '学院、博客与工具箱', url: '/pages/mine/index' },
|
||||
].map((s) => (
|
||||
<View key={s.n} className='journey-card'>
|
||||
<View className='journey-card-top'>
|
||||
<Text className='journey-n'>{s.n}</Text>
|
||||
<Text className='journey-emoji'>{s.e}</Text>
|
||||
</View>
|
||||
<Text className='journey-title'>{s.t}</Text>
|
||||
<Text className='journey-desc'>{s.p}</Text>
|
||||
<Button className='btn btn-primary btn-sm' onClick={() => Taro.switchTab({ url: s.url })}>
|
||||
开始
|
||||
</Button>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<DestinationsSection list={dests} limit={6} />
|
||||
|
||||
<View className='section section-compact'>
|
||||
<View className='container'>
|
||||
<Text className='section-tag'>{LIFESTYLE.tag}</Text>
|
||||
<Text className='section-h2'>{LIFESTYLE.title}</Text>
|
||||
<Text className='section-p'>{LIFESTYLE.subtitle}</Text>
|
||||
<View className='filter-tags' style={{ marginTop: '20px' }}>
|
||||
{LIFESTYLE.items.map((it, i) => (
|
||||
<Button
|
||||
key={it.key}
|
||||
className={`filter-btn${lifeActive === i ? ' active' : ''}`}
|
||||
onClick={() => setLifeActive(i)}
|
||||
>
|
||||
{it.icon} {it.title}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
{curLife ? (
|
||||
<View className='life-feature'>
|
||||
<Text className='section-tag'>{curLife.time}</Text>
|
||||
<Text className='section-h2'>{curLife.icon} {curLife.title}</Text>
|
||||
<Text className='section-p'>{curLife.desc}</Text>
|
||||
<Text className='section-p'>{curLife.tip}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section section-compact'>
|
||||
<View className='container'>
|
||||
<Text className='section-tag'>📋 VISA</Text>
|
||||
<Text className='section-h2'>远程友好签证</Text>
|
||||
<Text className='section-p'>出发前请再核对官方政策</Text>
|
||||
<View className='filter-tags' style={{ marginTop: '16px' }}>
|
||||
{[
|
||||
{ key: 'all', label: '🌏 全部' },
|
||||
{ key: 'easy', label: '✅ 简单' },
|
||||
{ key: 'medium', label: '📋 中等' },
|
||||
{ key: 'budget', label: '💰 低成本' },
|
||||
].map((f) => (
|
||||
<Button
|
||||
key={f.key}
|
||||
className={`filter-btn${visaFilter === f.key ? ' active' : ''}`}
|
||||
onClick={() => setVisaFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
{visaList.slice(0, 8).map((v) => (
|
||||
<View key={v.id} className='visa-card'>
|
||||
<Text className='visa-flag'>{v.flag}</Text>
|
||||
<Text className='journey-title'>{v.name}</Text>
|
||||
<Text className='visa-badge'>{v.badge}</Text>
|
||||
<Text className='visa-line'>📅 {v.duration}</Text>
|
||||
<Text className='visa-line'>💰 {v.income_req}</Text>
|
||||
<Text className='visa-line'>⏱️ {v.approval_time}</Text>
|
||||
<Text className='visa-line'>{v.extra}</Text>
|
||||
<View className='progress-bar'>
|
||||
<View className='progress-fill' style={{ width: `${v.difficulty}%` }} />
|
||||
</View>
|
||||
<Text className='visa-line'>难度 {v.difficulty_label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section section-compact'>
|
||||
<View className='container'>
|
||||
<Text className='section-tag'>🤝 COMMUNITY</Text>
|
||||
<Text className='section-h2'>游民心声</Text>
|
||||
<Text className='section-p'>来自全球数字游民的真实故事</Text>
|
||||
{voices.slice(0, 6).map((item) => (
|
||||
<View key={item.id} className='voice-card'>
|
||||
<Text className='voice-avatar'>{item.avatar}</Text>
|
||||
<Text className='voice-stars'>{'⭐'.repeat(item.rating || 5)}</Text>
|
||||
<Text className='voice-content'>“{item.content}”</Text>
|
||||
<Text className='voice-author'>{item.author} · {item.role}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section section-compact'>
|
||||
<View className='container'>
|
||||
<Text className='section-tag'>❓ FAQ</Text>
|
||||
<Text className='section-h2'>常见问题</Text>
|
||||
{FAQS.map((f, i) => (
|
||||
<View key={i} className='faq-item' onClick={() => setFaqOpen(faqOpen === i ? null : i)}>
|
||||
<Text className='faq-q'>{f.q}</Text>
|
||||
{faqOpen === i ? <Text className='faq-a'>{f.a}</Text> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SiteFooter />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +1 @@
|
||||
|
||||
.list { height: 100vh; padding: 32px 28px 80px; box-sizing: border-box; }
|
||||
.h { display: block; font-size: 40px; font-weight: 700; margin-bottom: 28px; }
|
||||
.err { display: block; color: #e88; margin-bottom: 16px; }
|
||||
.empty { display: block; color: #8a8694; margin-top: 40px; text-align: center; }
|
||||
.card { background: rgba(255,255,255,0.06); border-radius: 20px; padding: 28px; margin-bottom: 20px; }
|
||||
.name { display: block; font-size: 32px; font-weight: 600; }
|
||||
.meta { display: block; margin-top: 10px; color: #a8a3b3; font-size: 24px; }
|
||||
.tag { display: block; margin-top: 8px; color: #8a8694; font-size: 22px; }
|
||||
/* meetups — shared theme */
|
||||
|
||||
@ -1,28 +1,106 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import { useLoad } from '@tarojs/taro'
|
||||
import { View, Text, Button, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { BrandNav, SiteFooter } from '../../components/Chrome'
|
||||
import { fetchMeetups, type Meetup } from '../../services/api'
|
||||
import './index.scss'
|
||||
|
||||
export default function MeetupsPage() {
|
||||
const [items, setItems] = useState<Meetup[]>([])
|
||||
const [list, setList] = useState<Meetup[]>([])
|
||||
const [mode, setMode] = useState<'all' | 'online' | 'offline' | 'hybrid'>('all')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败'))
|
||||
fetchMeetups()
|
||||
.then((d) => { setList(d); setLoading(false) })
|
||||
.catch((e) => { setErr(e.message || '加载失败'); setLoading(false) })
|
||||
})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (mode === 'all') return list
|
||||
return list.filter((m) => (m.mode || '').toLowerCase() === mode)
|
||||
}, [list, mode])
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='list'>
|
||||
<Text className='h'>游民活动</Text>
|
||||
{err ? <Text className='err'>{err}</Text> : null}
|
||||
{items.map((m) => (
|
||||
<View key={m.id} className='card'>
|
||||
<Text className='name'>{m.title}</Text>
|
||||
<Text className='meta'>{m.city || '全球'} · {m.date || '待定'} · {m.attendees ?? 0} 人</Text>
|
||||
<ScrollView scrollY className='page-shell' showScrollbar={false}>
|
||||
<BrandNav chip='活动' />
|
||||
<View className='container' style={{ paddingBottom: '48px' }}>
|
||||
<Text className='section-tag'>🎉 MEETUPS</Text>
|
||||
<Text className='section-h2'>游民活动</Text>
|
||||
<Text className='section-p'>同城与线上 · 登录后可 RSVP</Text>
|
||||
|
||||
<View className='meetups-toolbar'>
|
||||
{([
|
||||
['all', '全部'],
|
||||
['online', '线上'],
|
||||
['offline', '线下'],
|
||||
['hybrid', '混合'],
|
||||
] as const).map(([k, label]) => (
|
||||
<Button
|
||||
key={k}
|
||||
className={`filter-btn${mode === k ? ' active' : ''}`}
|
||||
onClick={() => setMode(k)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
{!err && items.length === 0 ? <Text className='empty'>暂无活动</Text> : null}
|
||||
|
||||
{loading ? <Text className='dest-empty'>加载中…</Text> : null}
|
||||
{err ? <Text className='dest-empty'>{err}</Text> : null}
|
||||
|
||||
{filtered.map((m) => (
|
||||
<View key={m.id} className='meetup-card'>
|
||||
<View className='meetup-card-top'>
|
||||
<Text className='meetup-emoji'>{m.emoji || '🎉'}</Text>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className='meetup-title'>{m.title}</Text>
|
||||
<Text className='meetup-meta'>
|
||||
{m.city || '—'} · {m.date || ''} {m.time || ''} · {m.mode || 'offline'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='meetup-desc'>{m.description || ''}</Text>
|
||||
{m.venue ? <Text className='meetup-venue'>📍 {m.venue}</Text> : null}
|
||||
<View className='meetup-tags'>
|
||||
{(m.tags || []).map((tag) => (
|
||||
<Text key={tag} className='meetup-tag'>{tag}</Text>
|
||||
))}
|
||||
</View>
|
||||
<View className='meetup-footer'>
|
||||
<Text className='meetup-footer-text'>
|
||||
{m.rsvp_count ?? m.attendees ?? 0}/{m.max_attendees || '∞'} 人
|
||||
</Text>
|
||||
<View style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button
|
||||
className='btn btn-sm btn-primary'
|
||||
onClick={() => Taro.showToast({ title: '请先在「我的」登录', icon: 'none' })}
|
||||
>
|
||||
RSVP
|
||||
</Button>
|
||||
{m.destination_slug ? (
|
||||
<Button
|
||||
className='btn btn-sm btn-ghost'
|
||||
onClick={() =>
|
||||
Taro.navigateTo({
|
||||
url: `/pages/destination/index?slug=${encodeURIComponent(m.destination_slug!)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
城市
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{!loading && !err && !filtered.length ? (
|
||||
<Text className='dest-empty'>暂无活动</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<SiteFooter />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1 @@
|
||||
|
||||
.mine { padding: 48px 32px; }
|
||||
.h { display: block; font-size: 40px; font-weight: 700; }
|
||||
.hint { display: block; margin: 20px 0 40px; color: #8a8694; font-size: 26px; }
|
||||
.cta { background: #f0c674 !important; color: #1a1520 !important; border-radius: 999px !important; }
|
||||
/* mine — shared theme */
|
||||
|
||||
@ -1,15 +1,141 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import { View, Text, Button, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrandNav, SiteFooter } from '../../components/Chrome'
|
||||
import { DestCard } from '../../components/DestCard'
|
||||
import { STORAGE_TOKEN } from '../../utils/config'
|
||||
import {
|
||||
fetchFavoriteDetails,
|
||||
fetchMe,
|
||||
fetchProfileStats,
|
||||
loginWechat,
|
||||
type AuthUser,
|
||||
type Destination,
|
||||
type ProfileStats,
|
||||
} from '../../services/api'
|
||||
import './index.scss'
|
||||
|
||||
export default function MinePage() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [stats, setStats] = useState<ProfileStats | null>(null)
|
||||
const [favs, setFavs] = useState<Destination[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function refresh() {
|
||||
const t = Taro.getStorageSync(STORAGE_TOKEN)
|
||||
if (!t) {
|
||||
setUser(null)
|
||||
setStats(null)
|
||||
setFavs([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const me = await fetchMe()
|
||||
setUser(me)
|
||||
const [s, f] = await Promise.all([
|
||||
fetchProfileStats().catch(() => null),
|
||||
fetchFavoriteDetails().catch(() => [] as Destination[]),
|
||||
])
|
||||
setStats(s)
|
||||
setFavs(f)
|
||||
} catch {
|
||||
Taro.removeStorageSync(STORAGE_TOKEN)
|
||||
setUser(null)
|
||||
setStats(null)
|
||||
setFavs([])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
async function onWechatLogin() {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const { code } = await Taro.login()
|
||||
if (!code) throw new Error('获取微信登录码失败')
|
||||
const res = await loginWechat(code)
|
||||
Taro.setStorageSync(STORAGE_TOKEN, res.token)
|
||||
setUser(res.user)
|
||||
await refresh()
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '登录失败'
|
||||
Taro.showToast({ title: msg.slice(0, 40), icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
Taro.removeStorageSync(STORAGE_TOKEN)
|
||||
setUser(null)
|
||||
setStats(null)
|
||||
setFavs([])
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='mine'>
|
||||
<Text className='h'>我的</Text>
|
||||
<Text className='hint'>登录与会员请使用网页版完整能力</Text>
|
||||
<Button className='cta' onClick={() => Taro.setClipboardData({ data: 'https://nomadweb.nomadro.com' })}>
|
||||
复制网站链接
|
||||
</Button>
|
||||
</View>
|
||||
<ScrollView scrollY className='page-shell' showScrollbar={false}>
|
||||
<BrandNav chip='我的' />
|
||||
<View className='container' style={{ paddingBottom: '48px' }}>
|
||||
<Text className='section-tag'>👤 MINE</Text>
|
||||
<Text className='section-h2'>我的</Text>
|
||||
|
||||
{!user ? (
|
||||
<View className='profile-header'>
|
||||
<View className='profile-avatar'><Text>🧑💻</Text></View>
|
||||
<Text className='profile-name'>未登录</Text>
|
||||
<Text className='profile-email'>微信一键登录,同步收藏与等级</Text>
|
||||
<Button className='btn btn-primary' loading={loading} onClick={onWechatLogin}>
|
||||
微信登录
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View className='profile-header'>
|
||||
<View className='profile-avatar'><Text>{user.avatar || '🟢'}</Text></View>
|
||||
<Text className='profile-name'>{user.name}</Text>
|
||||
<Text className='profile-email'>{user.email}</Text>
|
||||
{stats ? <Text className='profile-level'>{stats.nomad_level}</Text> : null}
|
||||
<Button className='btn btn-ghost' onClick={onLogout}>退出登录</Button>
|
||||
</View>
|
||||
|
||||
{stats ? (
|
||||
<View className='profile-stats'>
|
||||
<View className='profile-stat'>
|
||||
<Text>❤️</Text>
|
||||
<Text className='profile-stat-n'>{stats.favorites_count}</Text>
|
||||
<Text className='profile-stat-l'>收藏</Text>
|
||||
</View>
|
||||
<View className='profile-stat'>
|
||||
<Text>🗺️</Text>
|
||||
<Text className='profile-stat-n'>{stats.destinations_explored}</Text>
|
||||
<Text className='profile-stat-l'>探索</Text>
|
||||
</View>
|
||||
<View className='profile-stat'>
|
||||
<Text>🏅</Text>
|
||||
<Text className='profile-stat-n'>{stats.member_since}</Text>
|
||||
<Text className='profile-stat-l'>加入</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text className='section-h2' style={{ fontSize: '32px', marginBottom: '16px' }}>❤️ 收藏的城市</Text>
|
||||
{favs.length ? favs.map((d) => <DestCard key={d.slug} d={d} favorited />) : (
|
||||
<Text className='dest-empty'>还没有收藏 · 去目的地逛逛</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='btn btn-ghost'
|
||||
style={{ marginTop: '24px', width: '100%' }}
|
||||
onClick={() => Taro.setClipboardData({ data: 'https://nomadweb.nomadro.com' })}
|
||||
>
|
||||
复制网页版链接
|
||||
</Button>
|
||||
</View>
|
||||
<SiteFooter />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,24 @@
|
||||
import { request } from '../utils/request'
|
||||
|
||||
export type Destination = {
|
||||
id?: string
|
||||
slug: string
|
||||
name: string
|
||||
country?: string
|
||||
emoji?: string
|
||||
tag?: string
|
||||
tagline?: string
|
||||
description?: string
|
||||
region?: string
|
||||
cost?: number
|
||||
speed?: number
|
||||
rating?: number
|
||||
tagline?: string
|
||||
temperature?: number
|
||||
hue?: number
|
||||
image?: string
|
||||
climate?: string
|
||||
nomads_count?: string
|
||||
highlights?: string[]
|
||||
}
|
||||
|
||||
export type Meetup = {
|
||||
@ -17,22 +26,105 @@ export type Meetup = {
|
||||
title: string
|
||||
city?: string
|
||||
date?: string
|
||||
time?: string
|
||||
cover?: string
|
||||
attendees?: number
|
||||
emoji?: string
|
||||
description?: string
|
||||
venue?: string
|
||||
mode?: string
|
||||
rsvp_count?: number
|
||||
max_attendees?: number
|
||||
tags?: string[]
|
||||
destination_slug?: string
|
||||
organizer?: string
|
||||
}
|
||||
|
||||
export type 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 type Testimonial = {
|
||||
id: string
|
||||
avatar: string
|
||||
content: string
|
||||
author: string
|
||||
role: string
|
||||
rating: number
|
||||
}
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
token: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export type ProfileStats = {
|
||||
favorites_count: number
|
||||
destinations_explored: number
|
||||
member_since: string
|
||||
nomad_level: string
|
||||
}
|
||||
|
||||
export function fetchDestinations() {
|
||||
return request<Destination[]>('/destinations')
|
||||
return request<Destination[]>({ url: '/destinations' })
|
||||
}
|
||||
|
||||
export function fetchDestination(slug: string) {
|
||||
return request<Destination>(`/destinations/${encodeURIComponent(slug)}`)
|
||||
return request<Destination>({ url: `/destinations/${encodeURIComponent(slug)}` })
|
||||
}
|
||||
|
||||
export function fetchMeetups() {
|
||||
return request<Meetup[]>('/meetups')
|
||||
return request<Meetup[]>({ url: '/meetups' })
|
||||
}
|
||||
|
||||
export function fetchStats() {
|
||||
return request<{ destinations?: number; members?: number; meetups?: number }>('/stats')
|
||||
return request<{ destinations?: number; members?: number; meetups?: number; total_nomads?: string }>({
|
||||
url: '/stats',
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchVisas() {
|
||||
return request<Visa[]>({ url: '/visas' })
|
||||
}
|
||||
|
||||
export function fetchTestimonials() {
|
||||
return request<Testimonial[]>({ url: '/testimonials' })
|
||||
}
|
||||
|
||||
export function loginWechat(code: string) {
|
||||
return request<AuthResponse>({
|
||||
url: '/auth/wechat',
|
||||
method: 'POST',
|
||||
data: { code },
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchMe() {
|
||||
return request<AuthUser>({ url: '/auth/me' })
|
||||
}
|
||||
|
||||
export function fetchProfileStats() {
|
||||
return request<ProfileStats>({ url: '/auth/profile/stats' })
|
||||
}
|
||||
|
||||
export function fetchFavoriteDetails() {
|
||||
return request<Destination[]>({ url: '/auth/favorites/detail' })
|
||||
}
|
||||
|
||||
653
wemini/src/styles/h5-theme.scss
Normal file
653
wemini/src/styles/h5-theme.scss
Normal file
@ -0,0 +1,653 @@
|
||||
/* Shared H5-parity blocks — weapp-safe only */
|
||||
|
||||
.brand-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 32px;
|
||||
}
|
||||
|
||||
.brand-nav-logo {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.brand-nav-chip {
|
||||
font-size: 22px;
|
||||
padding: 6px 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 198, 116, 0.14);
|
||||
color: #f0c674;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: 48px;
|
||||
padding: 48px 32px 80px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.site-footer-logo {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.site-footer-tag,
|
||||
.site-footer-copy {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
color: #64748b;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Dest card */
|
||||
.dest-card {
|
||||
background: rgba(17, 24, 39, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dest-img {
|
||||
position: relative;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dest-emoji {
|
||||
font-size: 88px;
|
||||
animation: emoji-bounce 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dest-overlay {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
.dest-tag {
|
||||
padding: 6px 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.dest-body {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.dest-card-title {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.dest-card-desc {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
line-height: 1.55;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
max-height: 76px;
|
||||
}
|
||||
|
||||
.dest-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dest-meta-item {
|
||||
font-size: 22px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dest-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.dest-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-search-input {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #f1f5f9;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-sort-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 24px 32px 48px;
|
||||
overflow: hidden;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.hero-shapes {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
opacity: 0.35;
|
||||
animation: aurora 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hero-shape-1 {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
background: #ff6b6b;
|
||||
top: 40px;
|
||||
left: -80px;
|
||||
}
|
||||
|
||||
.hero-shape-2 {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
background: #4ecdc4;
|
||||
top: 280px;
|
||||
right: -60px;
|
||||
animation-delay: -3s;
|
||||
}
|
||||
|
||||
.hero-shape-3 {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: #ffe66d;
|
||||
bottom: 40px;
|
||||
left: 30%;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
animation: fade-up 0.65s ease both;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.hero-badge-text {
|
||||
font-size: 22px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 64px;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-title-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
color: #4ecdc4;
|
||||
background: linear-gradient(135deg, #ff6b6b, #ffe66d, #4ecdc4);
|
||||
-webkit-background-clip: text;
|
||||
/* weapp may ignore clip — solid accent fallback above */
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
min-height: 96px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.typewriter-cursor {
|
||||
color: #4ecdc4;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.hero-globe {
|
||||
position: relative;
|
||||
height: 320px;
|
||||
margin: 8px 0 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-globe-core {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, rgba(78, 205, 196, 0.45), rgba(10, 14, 23, 0.2) 55%, transparent 70%);
|
||||
border: 1px solid rgba(78, 205, 196, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: float-y 4.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hero-globe-emoji {
|
||||
font-size: 96px;
|
||||
}
|
||||
|
||||
.float-card {
|
||||
position: absolute;
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(17, 24, 39, 0.88);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-size: 20px;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||
animation: float-y 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fc-1 { left: 0; top: 24px; animation-delay: 0s; }
|
||||
.fc-2 { right: 0; top: 80px; animation-delay: 0.8s; }
|
||||
.fc-3 { left: 40px; bottom: 12px; animation-delay: 1.4s; }
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hero-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticker-bar {
|
||||
overflow: hidden;
|
||||
height: 52px;
|
||||
background: rgba(10, 14, 23, 0.92);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ticker-track {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
white-space: nowrap;
|
||||
animation: ticker-scroll 28s linear infinite;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.ticker-item {
|
||||
flex-shrink: 0;
|
||||
padding: 0 36px;
|
||||
font-size: 22px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Journey */
|
||||
.journey-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.journey-card {
|
||||
padding: 28px 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(17, 24, 39, 0.72);
|
||||
}
|
||||
|
||||
.journey-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.journey-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: #ffe66d;
|
||||
}
|
||||
|
||||
.journey-emoji {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.journey-title {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.journey-desc {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Visa / lifestyle / voices / faq */
|
||||
.visa-grid,
|
||||
.life-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.visa-card,
|
||||
.life-feature,
|
||||
.voice-card,
|
||||
.faq-item {
|
||||
padding: 28px 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(17, 24, 39, 0.72);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.visa-flag {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.visa-badge {
|
||||
display: inline-block;
|
||||
margin: 8px 0 12px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 20px;
|
||||
background: rgba(78, 205, 196, 0.18);
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
.visa-line {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #94a3b8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
margin: 12px 0 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ff6b6b, #4ecdc4);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.voice-avatar {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.voice-stars {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
line-height: 1.6;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.voice-author {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.faq-q {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq-a {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 24px;
|
||||
line-height: 1.6;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Meetups */
|
||||
.meetup-card {
|
||||
background: rgba(17, 24, 39, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 28px;
|
||||
padding: 28px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.meetup-card-top {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.meetup-emoji {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.meetup-title {
|
||||
display: block;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meetup-meta,
|
||||
.meetup-venue,
|
||||
.meetup-desc {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #94a3b8;
|
||||
margin-top: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meetup-desc {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.meetup-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.meetup-tag {
|
||||
font-size: 20px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.meetup-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meetup-footer-text {
|
||||
font-size: 22px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.meetups-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
|
||||
/* Profile */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 56px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-email {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.profile-level {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(78, 205, 196, 0.15);
|
||||
color: #4ecdc4;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.profile-stat {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 24px 16px;
|
||||
border-radius: 20px;
|
||||
background: rgba(17, 24, 39, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-stat-n {
|
||||
display: block;
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.profile-stat-l {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
border-radius: 24px;
|
||||
padding: 36px 28px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.detail-nav {
|
||||
padding: 16px 0 8px;
|
||||
font-size: 26px;
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(700px 400px at 8% -6%, rgba(255, 107, 107, 0.18), transparent 55%),
|
||||
radial-gradient(640px 380px at 100% 0%, rgba(78, 205, 196, 0.16), transparent 52%),
|
||||
linear-gradient(168deg, #0a0e17 0%, #111827 48%, #0a0e17 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user