Add WeChat, Douyin, and offline XHS mini-program scaffolds.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-09-27 12:57:18 -05:00
parent 5c36032a46
commit 73b0513b9c
77 changed files with 1296 additions and 4 deletions

7
.gitignore vendored
View File

@ -29,6 +29,13 @@ pocketbase/pb_data/*
frontend/.next/
frontend/out/
# Mini programs
wemini/node_modules/
wemini/dist/
dymini/node_modules/
dymini/dist/
redmini/dist/
# Python
*.pyc
.venv/

View File

@ -46,11 +46,24 @@ nomadweb/
│ ├── app/routers/ # REST
│ ├── app/services/ # PocketBase、S3、城市深度等
│ └── app/data/ # Mock 降级(开发)
├── wemini/ # 微信小程序(Taro → weapp,可联网)
├── dymini/ # 抖音小程序(Taro → tt,可联网)
├── redmini/ # 小红书 miniTool(纯前端离线,禁止联网)
├── deploy/ # Caddy、systemd、env 模板
├── scripts/ # 本地开发与生产更新
└── pocketbase/ # PB 数据目录(本地可选)
```
### 小程序
| 目录 | 平台 | 联网 | 说明 |
|------|------|------|------|
| `wemini/` | 微信 | ✅ → `nomadweb.nomadro.com/api/v1` | `npm run dev:weapp` |
| `dymini/` | 抖音 | ✅ → 同上 | `npm run dev:tt` |
| `redmini/` | 小红书 | ❌ 纯离线 | `python scripts/assemble.py && python scripts/pack.py` |
Git 远程:`origin`(gitea)与 `nomadro`([git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git))。
## 快速开始
### 1. PocketBase(可选)
@ -149,13 +162,18 @@ NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
## Git 与部署
仓库:[gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb) · 分支 **`dev1`**
仓库:
- [gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb)(`origin`)
- [git.nomadro.cn/hackrobot/nomadweb](https://git.nomadro.cn/hackrobot/nomadweb.git)(`nomadro`)
当前开发分支 **`dev2`**(亦保留 `dev1` / `main`)。
```bash
git checkout dev1
git pull origin dev1
git checkout dev2
git pull origin dev2
# … 改代码 …
git push origin dev1
git push origin dev2
git push nomadro dev2
python scripts/deploy_update.py # 服务器 pull + 增量构建重启
```

15
dymini/README.md Normal file
View File

@ -0,0 +1,15 @@
# dymini · nomadro 抖音小程序
参考 Desktop/dymini(Taro 4 + React / tt),对接:
`https://nomadweb.nomadro.com/api/v1`
## 开发
```bash
cd dymini
npm install --legacy-peer-deps
npm run dev:tt
```
抖音开发者工具打开 `dymini/dist`。请替换 `appid` 并配置合法域名。

3
dymini/babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]],
}

5
dymini/config/dev.ts Normal file
View File

@ -0,0 +1,5 @@
export default {
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'http://127.0.0.1:8000/api/v1'),
},
}

37
dymini/config/index.ts Normal file
View File

@ -0,0 +1,37 @@
import path from 'path'
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
export default defineConfig(async (merge) => {
const baseConfig: UserConfigExport = {
projectName: 'dymini',
date: '2026-9-27',
designWidth: 750,
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-framework-react'],
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
},
copy: { patterns: [], options: {} },
framework: 'react',
compiler: { type: 'webpack5', prebundle: { enable: false } },
cache: { enable: false },
alias: { '@': path.resolve(__dirname, '..', 'src') },
mini: {
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
},
},
h5: {
publicPath: '/',
staticDirectory: 'static',
postcss: { autoprefixer: { enable: true }, cssModules: { enable: false } },
},
}
if (process.env.NODE_ENV === 'development') {
return merge({}, baseConfig, { logger: { quiet: false, stats: true } })
}
return merge({}, baseConfig)
})

5
dymini/config/prod.ts Normal file
View File

@ -0,0 +1,5 @@
export default {
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
},
}

33
dymini/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "nomadweb-dymini",
"version": "1.0.0",
"private": true,
"description": "nomadro 抖音小程序",
"scripts": {
"build:tt": "taro build --type tt",
"dev:tt": "npm run build:tt -- --watch"
},
"dependencies": {
"@babel/runtime": "^7.26.0",
"@tarojs/components": "4.0.9",
"@tarojs/helper": "4.0.9",
"@tarojs/plugin-framework-react": "4.0.9",
"@tarojs/plugin-platform-tt": "4.0.9",
"@tarojs/react": "4.0.9",
"@tarojs/runtime": "4.0.9",
"@tarojs/shared": "4.0.9",
"@tarojs/taro": "4.0.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@tarojs/cli": "4.0.9",
"@tarojs/webpack5-runner": "4.0.9",
"@types/react": "^18.3.12",
"babel-preset-taro": "4.0.9",
"sass": "^1.81.0",
"typescript": "^5.6.3",
"webpack": "5.91.0"
}
}

View File

@ -0,0 +1,12 @@
{
"miniprogramRoot": "dist/",
"projectname": "nomadweb-dymini",
"description": "nomadro 抖音小程序",
"appid": "ttXXXXXXXX",
"setting": {
"urlCheck": false,
"es6": false,
"minified": false
},
"compileType": "microApp"
}

10
dymini/project.tt.json Normal file
View File

@ -0,0 +1,10 @@
{
"miniprogramRoot": "./",
"projectname": "nomadweb-dymini",
"description": "nomadro 抖音小程序",
"appid": "ttXXXXXXXX",
"setting": {
"urlCheck": false
},
"compileType": "microApp"
}

28
dymini/src/app.config.ts Normal file
View File

@ -0,0 +1,28 @@
export default defineAppConfig({
pages: [
'pages/home/index',
'pages/destinations/index',
'pages/destination/index',
'pages/meetups/index',
'pages/mine/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#0c0b12',
navigationBarTitleText: 'nomadro',
navigationBarTextStyle: 'white',
backgroundColor: '#0c0b12',
},
tabBar: {
color: '#8a8694',
selectedColor: '#f0c674',
backgroundColor: '#12111a',
borderStyle: 'black',
list: [
{ pagePath: 'pages/home/index', text: '首页' },
{ pagePath: 'pages/destinations/index', text: '目的地' },
{ pagePath: 'pages/meetups/index', text: '活动' },
{ pagePath: 'pages/mine/index', text: '我的' },
],
},
})

5
dymini/src/app.scss Normal file
View File

@ -0,0 +1,5 @@
page {
background: #0c0b12;
color: #f4f0ea;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

8
dymini/src/app.tsx Normal file
View File

@ -0,0 +1,8 @@
import { PropsWithChildren } from 'react'
import './app.scss'
function App({ children }: PropsWithChildren) {
return children
}
export default App

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '城市详情' })

View File

@ -0,0 +1,10 @@
.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; }

View File

@ -0,0 +1,33 @@
import { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { fetchDestination, type Destination } from '../../services/api'
import './index.scss'
export default function DestinationPage() {
const [d, setD] = useState<Destination | null>(null)
const [err, setErr] = useState('')
useLoad((q) => {
const slug = String(q.slug || '')
if (!slug) { setErr('缺少城市'); return }
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>
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>
</View>
</View>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '目的地' })

View File

@ -0,0 +1,9 @@
.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; }

View File

@ -0,0 +1,34 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { fetchDestinations, type Destination } from '../../services/api'
import './index.scss'
export default function DestinationsPage() {
const [items, setItems] = useState<Destination[]>([])
const [err, setErr] = useState('')
useLoad(() => {
fetchDestinations()
.then(setItems)
.catch((e) => setErr(e.message || '加载失败'))
})
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>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '首页' })

View File

@ -0,0 +1,12 @@
.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; }

View File

@ -0,0 +1,32 @@
import { useState } from 'react'
import { View, Text, Button } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { fetchStats } from '../../services/api'
import './index.scss'
export default function HomePage() {
const [stats, setStats] = useState<{ destinations?: number; members?: number; meetups?: number } | null>(null)
const [err, setErr] = useState('')
useLoad(() => {
fetchStats()
.then(setStats)
.catch((e) => setErr(e.message || '加载失败'))
})
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>
</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>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '活动' })

View File

@ -0,0 +1,9 @@
.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; }

View File

@ -0,0 +1,28 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { fetchMeetups, type Meetup } from '../../services/api'
import './index.scss'
export default function MeetupsPage() {
const [items, setItems] = useState<Meetup[]>([])
const [err, setErr] = useState('')
useLoad(() => {
fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败'))
})
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>
</View>
))}
{!err && items.length === 0 ? <Text className='empty'>暂无活动</Text> : null}
</ScrollView>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '我的' })

View File

@ -0,0 +1,5 @@
.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; }

View File

@ -0,0 +1,15 @@
import { View, Text, Button } from '@tarojs/components'
import Taro from '@tarojs/taro'
import './index.scss'
export default function MinePage() {
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>
)
}

View File

@ -0,0 +1,38 @@
import { request } from '../utils/request'
export type Destination = {
slug: string
name: string
country?: string
cost?: number
speed?: number
rating?: number
tagline?: string
image?: string
climate?: string
}
export type Meetup = {
id: string
title: string
city?: string
date?: string
cover?: string
attendees?: number
}
export function fetchDestinations() {
return request<Destination[]>('/destinations')
}
export function fetchDestination(slug: string) {
return request<Destination>(`/destinations/${encodeURIComponent(slug)}`)
}
export function fetchMeetups() {
return request<Meetup[]>('/meetups')
}
export function fetchStats() {
return request<{ destinations?: number; members?: number; meetups?: number }>('/stats')
}

View File

@ -0,0 +1,8 @@
declare const TARO_APP_API: string
export const API_BASE =
typeof TARO_APP_API === 'string' && TARO_APP_API
? TARO_APP_API
: 'https://nomadweb.nomadro.com/api/v1'
export const STORAGE_TOKEN = 'dymini_token'

View File

@ -0,0 +1,28 @@
import Taro from '@tarojs/taro'
import { API_BASE, STORAGE_TOKEN } from './config'
type Opts = {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: Record<string, unknown>
header?: Record<string, string>
}
export async function request<T = unknown>(options: Opts): Promise<T> {
const token = Taro.getStorageSync(STORAGE_TOKEN)
const res = await Taro.request({
url: `${API_BASE}${options.url}`,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.header || {}),
},
})
if (res.statusCode >= 400) {
const msg = (res.data as any)?.detail || (res.data as any)?.message || '请求失败'
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))
}
return res.data as T
}

24
dymini/tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"jsx": "react-jsx",
"strictNullChecks": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"typeRoots": [
"node_modules/@types"
]
},
"include": [
"./src",
"./types",
"./config"
]
}

3
dymini/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare function defineAppConfig(config: Record<string, unknown>): Record<string, unknown>
declare function definePageConfig(config: Record<string, unknown>): Record<string, unknown>
declare const TARO_APP_API: string

25
redmini/README.md Normal file
View File

@ -0,0 +1,25 @@
# redmini · nomadro 小红书小程序(纯前端离线)
参考 Desktop/redmini:小红书 **miniTool 无法联网**,本目录禁止 `fetch` / XHR / WebSocket。
数据写死在 `src/data.js`,收藏仅存本地 Storage。
## 打包
```bash
cd redmini
python scripts/assemble.py
python scripts/static_scan.py
python scripts/pack.py
```
产出:`dist/nomadro-redmini.zip`(根目录含 `index.html`),用小红书开发者工具上传。
## 目录
| 路径 | 说明 |
|------|------|
| `src/data.js` | 离线目的地与提示文案 |
| `tool/` | 源码 HTML/CSS/JS |
| `release/` | assemble 产物 |
| `scripts/` | assemble / scan / pack |

42
redmini/release/app.js Normal file
View File

@ -0,0 +1,42 @@
(function () {
'use strict';
var data = window.NOMADRO_DATA || { destinations: [], tips: [] };
var root = document.getElementById('app');
var favKey = 'nomadro_redmini_favs';
function render(favs) {
favs = favs || [];
var cards = (data.destinations || []).map(function (d) {
var marked = favs.indexOf(d.slug) >= 0 ? ' · 已收藏' : '';
return (
'<article class="card" data-slug="' + d.slug + '">' +
'<div class="name">' + d.name + '</div>' +
'<div class="meta">' + (d.country || '') + ' · ¥' + (d.cost || '—') + '/月 · ★' + (d.rating || '—') + '</div>' +
'<div class="meta">' + (d.tagline || '') + '</div>' +
'<div class="fav">' + (marked || '点按收藏(仅本地)') + '</div>' +
'</article>'
);
}).join('');
var tips = (data.tips || []).map(function (t) { return '<li>' + t + '</li>'; }).join('');
root.innerHTML =
'<p class="brand">' + (data.brand || 'nomadro') + '</p>' +
'<p class="tag">' + (data.tagline || '') + '</p>' +
'<p class="note">' + (data.note || '') + '</p>' +
cards +
'<section class="tips"><h2>离线提示</h2><ul>' + tips + '</ul></section>';
Array.prototype.forEach.call(root.querySelectorAll('.card'), function (el) {
el.addEventListener('click', function () {
var slug = el.getAttribute('data-slug');
var next = favs.slice();
var i = next.indexOf(slug);
if (i >= 0) next.splice(i, 1); else next.push(slug);
window.NomadroXhs.setLocalData(favKey, next).then(function () { render(next); });
});
});
}
window.NomadroXhs.getLocalData(favKey).then(function (favs) {
render(Array.isArray(favs) ? favs : []);
});
})();

19
redmini/release/data.js Normal file
View File

@ -0,0 +1,19 @@
window.NOMADRO_DATA = {
brand: 'nomadro',
tagline: '用一行代码环游世界',
note: '小红书小程序无法联网,本包为纯前端离线资料',
destinations: [
{ slug: 'chiang-mai', name: '清迈', country: '泰国', cost: 4500, speed: 80, rating: 4.7, tagline: '咖啡与 coworking' },
{ slug: 'bali', name: '巴厘岛', country: '印尼', cost: 6000, speed: 60, rating: 4.5, tagline: '海边远程' },
{ slug: 'lisbon', name: '里斯本', country: '葡萄牙', cost: 12000, speed: 120, rating: 4.6, tagline: '欧洲数字游民枢纽' },
{ slug: 'da-nang', name: '岘港', country: '越南', cost: 5000, speed: 90, rating: 4.4, tagline: '性价比海岸' },
{ slug: 'mexico-city', name: '墨西哥城', country: '墨西哥', cost: 9000, speed: 70, rating: 4.3, tagline: '美食与时区友好' },
{ slug: 'bangkok', name: '曼谷', country: '泰国', cost: 5500, speed: 100, rating: 4.5, tagline: '交通便利大城' }
],
tips: [
'先定预算与签证天数,再选城市。',
'到站第一周优先搞定住宿、SIM 与 coworking。',
'同城活动是认识同行最快的方式。',
'完整功能请打开 nomadweb.nomadro.com(本包离线不可请求网络)。'
]
};

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>nomadro · 离线目的地</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app"></div>
<script src="./data.js"></script>
<script src="./xhs-bridge.js"></script>
<script src="./app.js"></script>
</body>
</html>

View File

@ -0,0 +1,12 @@
*{box-sizing:border-box}html,body{margin:0;padding:0;background:#0c0b12;color:#f4f0ea;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
#app{min-height:100vh;padding:28px 20px 48px}
.brand{font-size:28px;font-weight:700;letter-spacing:1px}
.tag{margin:8px 0 6px;color:#a8a3b3;font-size:14px}
.note{color:#c9a227;font-size:12px;margin-bottom:20px}
.card{background:rgba(255,255,255,.06);border-radius:14px;padding:14px;margin-bottom:12px}
.name{font-size:17px;font-weight:600}
.meta{margin-top:6px;color:#a8a3b3;font-size:13px}
.tips{margin-top:24px}
.tips h2{font-size:16px;margin:0 0 10px}
.tips li{margin:8px 0;color:#cfc9d8;font-size:13px;line-height:1.5}
.fav{margin-top:10px;font-size:12px;color:#f0c674}

View File

@ -0,0 +1,40 @@
(function (global) {
'use strict';
function getMiniTool() {
return global.xhs && global.xhs.miniTool ? global.xhs.miniTool : null;
}
function setLocalData(key, data) {
var serialized = JSON.stringify(data);
var mt = getMiniTool();
if (mt && typeof mt.setStorage === 'function') {
return Promise.resolve(mt.setStorage({ key: key, data: serialized }))
.then(function () { return true; })
.catch(function () {
try { localStorage.setItem(key, serialized); return true; } catch (e) { return false; }
});
}
try { localStorage.setItem(key, serialized); return Promise.resolve(true); } catch (e) {
return Promise.resolve(false);
}
}
function getLocalData(key) {
var mt = getMiniTool();
if (mt && typeof mt.getStorage === 'function') {
return Promise.resolve(mt.getStorage({ key: key }))
.then(function (res) {
var raw = res && (res.data || res);
if (typeof raw === 'string') {
try { return JSON.parse(raw); } catch (e) { return null; }
}
return raw || null;
})
.catch(function () {
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; }
});
}
try { return Promise.resolve(JSON.parse(localStorage.getItem(key) || 'null')); } catch (e) {
return Promise.resolve(null);
}
}
global.NomadroXhs = { setLocalData: setLocalData, getLocalData: getLocalData };
})(window);

View File

@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Copy tool/ into release/ and embed src/data.js (no network)."""
from __future__ import annotations
import shutil
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TOOL = ROOT / "tool"
SRC_DATA = ROOT / "src" / "data.js"
RELEASE = ROOT / "release"
def main() -> int:
if RELEASE.exists():
shutil.rmtree(RELEASE)
RELEASE.mkdir(parents=True)
for name in ("index.html", "styles.css", "app.js", "xhs-bridge.js"):
shutil.copy2(TOOL / name, RELEASE / name)
shutil.copy2(SRC_DATA, RELEASE / "data.js")
print(f"assembled {RELEASE}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

30
redmini/scripts/pack.py Normal file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Pack release/ into uploadable zip (index.html at zip root)."""
from __future__ import annotations
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TOOL = ROOT / "release"
OUT = ROOT / "dist" / "nomadro-redmini.zip"
def main() -> int:
out = Path(sys.argv[1]) if len(sys.argv) > 1 else OUT
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists():
out.unlink()
files = sorted(p for p in TOOL.rglob("*") if p.is_file())
if not files:
print("release/ empty — run: python scripts/assemble.py", file=sys.stderr)
return 1
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in files:
zf.write(p, p.relative_to(TOOL).as_posix())
print(f"packed {out} ({out.stat().st_size / 1024:.1f} KB)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Fail if release/ contains networking APIs (XHS miniTool rule)."""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RELEASE = ROOT / "release"
BANNED = re.compile(
r"\b(fetch|XMLHttpRequest|WebSocket|navigator\.sendBeacon)\b|"
r"https?://(?!miniapp\.xiaohongshu\.com)",
re.I,
)
def main() -> int:
bad = []
for p in RELEASE.rglob("*"):
if not p.is_file() or p.suffix.lower() not in {".js", ".html", ".css"}:
continue
text = p.read_text(encoding="utf-8", errors="replace")
if BANNED.search(text):
bad.append(str(p.relative_to(ROOT)))
if bad:
print("NETWORK_FORBIDDEN:", ", ".join(bad), file=sys.stderr)
return 1
print("offline_ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())

19
redmini/src/data.js Normal file
View File

@ -0,0 +1,19 @@
window.NOMADRO_DATA = {
brand: 'nomadro',
tagline: '用一行代码环游世界',
note: '小红书小程序无法联网,本包为纯前端离线资料',
destinations: [
{ slug: 'chiang-mai', name: '清迈', country: '泰国', cost: 4500, speed: 80, rating: 4.7, tagline: '咖啡与 coworking' },
{ slug: 'bali', name: '巴厘岛', country: '印尼', cost: 6000, speed: 60, rating: 4.5, tagline: '海边远程' },
{ slug: 'lisbon', name: '里斯本', country: '葡萄牙', cost: 12000, speed: 120, rating: 4.6, tagline: '欧洲数字游民枢纽' },
{ slug: 'da-nang', name: '岘港', country: '越南', cost: 5000, speed: 90, rating: 4.4, tagline: '性价比海岸' },
{ slug: 'mexico-city', name: '墨西哥城', country: '墨西哥', cost: 9000, speed: 70, rating: 4.3, tagline: '美食与时区友好' },
{ slug: 'bangkok', name: '曼谷', country: '泰国', cost: 5500, speed: 100, rating: 4.5, tagline: '交通便利大城' }
],
tips: [
'先定预算与签证天数,再选城市。',
'到站第一周优先搞定住宿、SIM 与 coworking。',
'同城活动是认识同行最快的方式。',
'完整功能请打开 nomadweb.nomadro.com(本包离线不可请求网络)。'
]
};

42
redmini/tool/app.js Normal file
View File

@ -0,0 +1,42 @@
(function () {
'use strict';
var data = window.NOMADRO_DATA || { destinations: [], tips: [] };
var root = document.getElementById('app');
var favKey = 'nomadro_redmini_favs';
function render(favs) {
favs = favs || [];
var cards = (data.destinations || []).map(function (d) {
var marked = favs.indexOf(d.slug) >= 0 ? ' · 已收藏' : '';
return (
'<article class="card" data-slug="' + d.slug + '">' +
'<div class="name">' + d.name + '</div>' +
'<div class="meta">' + (d.country || '') + ' · ¥' + (d.cost || '—') + '/月 · ★' + (d.rating || '—') + '</div>' +
'<div class="meta">' + (d.tagline || '') + '</div>' +
'<div class="fav">' + (marked || '点按收藏(仅本地)') + '</div>' +
'</article>'
);
}).join('');
var tips = (data.tips || []).map(function (t) { return '<li>' + t + '</li>'; }).join('');
root.innerHTML =
'<p class="brand">' + (data.brand || 'nomadro') + '</p>' +
'<p class="tag">' + (data.tagline || '') + '</p>' +
'<p class="note">' + (data.note || '') + '</p>' +
cards +
'<section class="tips"><h2>离线提示</h2><ul>' + tips + '</ul></section>';
Array.prototype.forEach.call(root.querySelectorAll('.card'), function (el) {
el.addEventListener('click', function () {
var slug = el.getAttribute('data-slug');
var next = favs.slice();
var i = next.indexOf(slug);
if (i >= 0) next.splice(i, 1); else next.push(slug);
window.NomadroXhs.setLocalData(favKey, next).then(function () { render(next); });
});
});
}
window.NomadroXhs.getLocalData(favKey).then(function (favs) {
render(Array.isArray(favs) ? favs : []);
});
})();

15
redmini/tool/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>nomadro · 离线目的地</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app"></div>
<script src="./data.js"></script>
<script src="./xhs-bridge.js"></script>
<script src="./app.js"></script>
</body>
</html>

12
redmini/tool/styles.css Normal file
View File

@ -0,0 +1,12 @@
*{box-sizing:border-box}html,body{margin:0;padding:0;background:#0c0b12;color:#f4f0ea;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
#app{min-height:100vh;padding:28px 20px 48px}
.brand{font-size:28px;font-weight:700;letter-spacing:1px}
.tag{margin:8px 0 6px;color:#a8a3b3;font-size:14px}
.note{color:#c9a227;font-size:12px;margin-bottom:20px}
.card{background:rgba(255,255,255,.06);border-radius:14px;padding:14px;margin-bottom:12px}
.name{font-size:17px;font-weight:600}
.meta{margin-top:6px;color:#a8a3b3;font-size:13px}
.tips{margin-top:24px}
.tips h2{font-size:16px;margin:0 0 10px}
.tips li{margin:8px 0;color:#cfc9d8;font-size:13px;line-height:1.5}
.fav{margin-top:10px;font-size:12px;color:#f0c674}

View File

@ -0,0 +1,40 @@
(function (global) {
'use strict';
function getMiniTool() {
return global.xhs && global.xhs.miniTool ? global.xhs.miniTool : null;
}
function setLocalData(key, data) {
var serialized = JSON.stringify(data);
var mt = getMiniTool();
if (mt && typeof mt.setStorage === 'function') {
return Promise.resolve(mt.setStorage({ key: key, data: serialized }))
.then(function () { return true; })
.catch(function () {
try { localStorage.setItem(key, serialized); return true; } catch (e) { return false; }
});
}
try { localStorage.setItem(key, serialized); return Promise.resolve(true); } catch (e) {
return Promise.resolve(false);
}
}
function getLocalData(key) {
var mt = getMiniTool();
if (mt && typeof mt.getStorage === 'function') {
return Promise.resolve(mt.getStorage({ key: key }))
.then(function (res) {
var raw = res && (res.data || res);
if (typeof raw === 'string') {
try { return JSON.parse(raw); } catch (e) { return null; }
}
return raw || null;
})
.catch(function () {
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; }
});
}
try { return Promise.resolve(JSON.parse(localStorage.getItem(key) || 'null')); } catch (e) {
return Promise.resolve(null);
}
}
global.NomadroXhs = { setLocalData: setLocalData, getLocalData: getLocalData };
})(window);

15
wemini/README.md Normal file
View File

@ -0,0 +1,15 @@
# wemini · nomadro 微信小程序
参考 Desktop/wemini(Taro 4 + React),对接:
`https://nomadweb.nomadro.com/api/v1`
## 开发
```bash
cd wemini
npm install --legacy-peer-deps
npm run dev:weapp
```
微信开发者工具打开本目录(`miniprogramRoot=dist/`)。请替换 `appid` 并配置合法 request 域名。

3
wemini/babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]],
}

5
wemini/config/dev.ts Normal file
View File

@ -0,0 +1,5 @@
export default {
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'http://127.0.0.1:8000/api/v1'),
},
}

37
wemini/config/index.ts Normal file
View File

@ -0,0 +1,37 @@
import path from 'path'
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
export default defineConfig(async (merge) => {
const baseConfig: UserConfigExport = {
projectName: 'wemini',
date: '2026-9-27',
designWidth: 750,
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-framework-react'],
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
},
copy: { patterns: [], options: {} },
framework: 'react',
compiler: { type: 'webpack5', prebundle: { enable: false } },
cache: { enable: false },
alias: { '@': path.resolve(__dirname, '..', 'src') },
mini: {
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
},
},
h5: {
publicPath: '/',
staticDirectory: 'static',
postcss: { autoprefixer: { enable: true }, cssModules: { enable: false } },
},
}
if (process.env.NODE_ENV === 'development') {
return merge({}, baseConfig, { logger: { quiet: false, stats: true } })
}
return merge({}, baseConfig)
})

5
wemini/config/prod.ts Normal file
View File

@ -0,0 +1,5 @@
export default {
defineConstants: {
TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'),
},
}

33
wemini/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "nomadweb-wemini",
"version": "1.0.0",
"private": true,
"description": "nomadro 微信小程序",
"scripts": {
"build:weapp": "taro build --type weapp",
"dev:weapp": "npm run build:weapp -- --watch"
},
"dependencies": {
"@babel/runtime": "^7.26.0",
"@tarojs/components": "4.0.9",
"@tarojs/helper": "4.0.9",
"@tarojs/plugin-framework-react": "4.0.9",
"@tarojs/plugin-platform-weapp": "4.0.9",
"@tarojs/react": "4.0.9",
"@tarojs/runtime": "4.0.9",
"@tarojs/shared": "4.0.9",
"@tarojs/taro": "4.0.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@tarojs/cli": "4.0.9",
"@tarojs/webpack5-runner": "4.0.9",
"@types/react": "^18.3.12",
"babel-preset-taro": "4.0.9",
"sass": "^1.81.0",
"typescript": "^5.6.3",
"webpack": "5.91.0"
}
}

View File

@ -0,0 +1,12 @@
{
"miniprogramRoot": "dist/",
"projectname": "nomadweb-wemini",
"description": "nomadro 微信小程序",
"appid": "touristappid",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true
},
"compileType": "miniprogram"
}

28
wemini/src/app.config.ts Normal file
View File

@ -0,0 +1,28 @@
export default defineAppConfig({
pages: [
'pages/home/index',
'pages/destinations/index',
'pages/destination/index',
'pages/meetups/index',
'pages/mine/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#0c0b12',
navigationBarTitleText: 'nomadro',
navigationBarTextStyle: 'white',
backgroundColor: '#0c0b12',
},
tabBar: {
color: '#8a8694',
selectedColor: '#f0c674',
backgroundColor: '#12111a',
borderStyle: 'black',
list: [
{ pagePath: 'pages/home/index', text: '首页' },
{ pagePath: 'pages/destinations/index', text: '目的地' },
{ pagePath: 'pages/meetups/index', text: '活动' },
{ pagePath: 'pages/mine/index', text: '我的' },
],
},
})

5
wemini/src/app.scss Normal file
View File

@ -0,0 +1,5 @@
page {
background: #0c0b12;
color: #f4f0ea;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

8
wemini/src/app.tsx Normal file
View File

@ -0,0 +1,8 @@
import { PropsWithChildren } from 'react'
import './app.scss'
function App({ children }: PropsWithChildren) {
return children
}
export default App

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '城市详情' })

View File

@ -0,0 +1,10 @@
.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; }

View File

@ -0,0 +1,33 @@
import { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { fetchDestination, type Destination } from '../../services/api'
import './index.scss'
export default function DestinationPage() {
const [d, setD] = useState<Destination | null>(null)
const [err, setErr] = useState('')
useLoad((q) => {
const slug = String(q.slug || '')
if (!slug) { setErr('缺少城市'); return }
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>
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>
</View>
</View>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '目的地' })

View File

@ -0,0 +1,9 @@
.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; }

View File

@ -0,0 +1,34 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { fetchDestinations, type Destination } from '../../services/api'
import './index.scss'
export default function DestinationsPage() {
const [items, setItems] = useState<Destination[]>([])
const [err, setErr] = useState('')
useLoad(() => {
fetchDestinations()
.then(setItems)
.catch((e) => setErr(e.message || '加载失败'))
})
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>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '首页' })

View File

@ -0,0 +1,12 @@
.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; }

View File

@ -0,0 +1,32 @@
import { useState } from 'react'
import { View, Text, Button } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { fetchStats } from '../../services/api'
import './index.scss'
export default function HomePage() {
const [stats, setStats] = useState<{ destinations?: number; members?: number; meetups?: number } | null>(null)
const [err, setErr] = useState('')
useLoad(() => {
fetchStats()
.then(setStats)
.catch((e) => setErr(e.message || '加载失败'))
})
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>
</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>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '活动' })

View File

@ -0,0 +1,9 @@
.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; }

View File

@ -0,0 +1,28 @@
import { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { fetchMeetups, type Meetup } from '../../services/api'
import './index.scss'
export default function MeetupsPage() {
const [items, setItems] = useState<Meetup[]>([])
const [err, setErr] = useState('')
useLoad(() => {
fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败'))
})
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>
</View>
))}
{!err && items.length === 0 ? <Text className='empty'>暂无活动</Text> : null}
</ScrollView>
)
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '我的' })

View File

@ -0,0 +1,5 @@
.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; }

View File

@ -0,0 +1,15 @@
import { View, Text, Button } from '@tarojs/components'
import Taro from '@tarojs/taro'
import './index.scss'
export default function MinePage() {
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>
)
}

View File

@ -0,0 +1,38 @@
import { request } from '../utils/request'
export type Destination = {
slug: string
name: string
country?: string
cost?: number
speed?: number
rating?: number
tagline?: string
image?: string
climate?: string
}
export type Meetup = {
id: string
title: string
city?: string
date?: string
cover?: string
attendees?: number
}
export function fetchDestinations() {
return request<Destination[]>('/destinations')
}
export function fetchDestination(slug: string) {
return request<Destination>(`/destinations/${encodeURIComponent(slug)}`)
}
export function fetchMeetups() {
return request<Meetup[]>('/meetups')
}
export function fetchStats() {
return request<{ destinations?: number; members?: number; meetups?: number }>('/stats')
}

View File

@ -0,0 +1,8 @@
declare const TARO_APP_API: string
export const API_BASE =
typeof TARO_APP_API === 'string' && TARO_APP_API
? TARO_APP_API
: 'https://nomadweb.nomadro.com/api/v1'
export const STORAGE_TOKEN = 'wemini_token'

View File

@ -0,0 +1,28 @@
import Taro from '@tarojs/taro'
import { API_BASE, STORAGE_TOKEN } from './config'
type Opts = {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: Record<string, unknown>
header?: Record<string, string>
}
export async function request<T = unknown>(options: Opts): Promise<T> {
const token = Taro.getStorageSync(STORAGE_TOKEN)
const res = await Taro.request({
url: `${API_BASE}${options.url}`,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.header || {}),
},
})
if (res.statusCode >= 400) {
const msg = (res.data as any)?.detail || (res.data as any)?.message || '请求失败'
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))
}
return res.data as T
}

24
wemini/tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"jsx": "react-jsx",
"strictNullChecks": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"typeRoots": [
"node_modules/@types"
]
},
"include": [
"./src",
"./types",
"./config"
]
}

3
wemini/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare function defineAppConfig(config: Record<string, unknown>): Record<string, unknown>
declare function definePageConfig(config: Record<string, unknown>): Record<string, unknown>
declare const TARO_APP_API: string