Add WeChat, Douyin, and offline XHS mini-program scaffolds.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5c36032a46
commit
73b0513b9c
7
.gitignore
vendored
7
.gitignore
vendored
@ -29,6 +29,13 @@ pocketbase/pb_data/*
|
|||||||
frontend/.next/
|
frontend/.next/
|
||||||
frontend/out/
|
frontend/out/
|
||||||
|
|
||||||
|
# Mini programs
|
||||||
|
wemini/node_modules/
|
||||||
|
wemini/dist/
|
||||||
|
dymini/node_modules/
|
||||||
|
dymini/dist/
|
||||||
|
redmini/dist/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
26
README.md
26
README.md
@ -46,11 +46,24 @@ nomadweb/
|
|||||||
│ ├── app/routers/ # REST
|
│ ├── app/routers/ # REST
|
||||||
│ ├── app/services/ # PocketBase、S3、城市深度等
|
│ ├── app/services/ # PocketBase、S3、城市深度等
|
||||||
│ └── app/data/ # Mock 降级(开发)
|
│ └── app/data/ # Mock 降级(开发)
|
||||||
|
├── wemini/ # 微信小程序(Taro → weapp,可联网)
|
||||||
|
├── dymini/ # 抖音小程序(Taro → tt,可联网)
|
||||||
|
├── redmini/ # 小红书 miniTool(纯前端离线,禁止联网)
|
||||||
├── deploy/ # Caddy、systemd、env 模板
|
├── deploy/ # Caddy、systemd、env 模板
|
||||||
├── scripts/ # 本地开发与生产更新
|
├── scripts/ # 本地开发与生产更新
|
||||||
└── pocketbase/ # PB 数据目录(本地可选)
|
└── 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(可选)
|
### 1. PocketBase(可选)
|
||||||
@ -149,13 +162,18 @@ NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
|
|||||||
|
|
||||||
## Git 与部署
|
## 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
|
```bash
|
||||||
git checkout dev1
|
git checkout dev2
|
||||||
git pull origin dev1
|
git pull origin dev2
|
||||||
# … 改代码 …
|
# … 改代码 …
|
||||||
git push origin dev1
|
git push origin dev2
|
||||||
|
git push nomadro dev2
|
||||||
python scripts/deploy_update.py # 服务器 pull + 增量构建重启
|
python scripts/deploy_update.py # 服务器 pull + 增量构建重启
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
15
dymini/README.md
Normal file
15
dymini/README.md
Normal 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
3
dymini/babel.config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]],
|
||||||
|
}
|
||||||
5
dymini/config/dev.ts
Normal file
5
dymini/config/dev.ts
Normal 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
37
dymini/config/index.ts
Normal 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
5
dymini/config/prod.ts
Normal 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
33
dymini/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
dymini/project.config.json
Normal file
12
dymini/project.config.json
Normal 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
10
dymini/project.tt.json
Normal 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
28
dymini/src/app.config.ts
Normal 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
5
dymini/src/app.scss
Normal 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
8
dymini/src/app.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
import './app.scss'
|
||||||
|
|
||||||
|
function App({ children }: PropsWithChildren) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
dymini/src/pages/destination/index.config.ts
Normal file
1
dymini/src/pages/destination/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '城市详情' })
|
||||||
10
dymini/src/pages/destination/index.scss
Normal file
10
dymini/src/pages/destination/index.scss
Normal 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; }
|
||||||
33
dymini/src/pages/destination/index.tsx
Normal file
33
dymini/src/pages/destination/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
dymini/src/pages/destinations/index.config.ts
Normal file
1
dymini/src/pages/destinations/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '目的地' })
|
||||||
9
dymini/src/pages/destinations/index.scss
Normal file
9
dymini/src/pages/destinations/index.scss
Normal 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; }
|
||||||
34
dymini/src/pages/destinations/index.tsx
Normal file
34
dymini/src/pages/destinations/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
dymini/src/pages/home/index.config.ts
Normal file
1
dymini/src/pages/home/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '首页' })
|
||||||
12
dymini/src/pages/home/index.scss
Normal file
12
dymini/src/pages/home/index.scss
Normal 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; }
|
||||||
32
dymini/src/pages/home/index.tsx
Normal file
32
dymini/src/pages/home/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
dymini/src/pages/meetups/index.config.ts
Normal file
1
dymini/src/pages/meetups/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '活动' })
|
||||||
9
dymini/src/pages/meetups/index.scss
Normal file
9
dymini/src/pages/meetups/index.scss
Normal 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; }
|
||||||
28
dymini/src/pages/meetups/index.tsx
Normal file
28
dymini/src/pages/meetups/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
dymini/src/pages/mine/index.config.ts
Normal file
1
dymini/src/pages/mine/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '我的' })
|
||||||
5
dymini/src/pages/mine/index.scss
Normal file
5
dymini/src/pages/mine/index.scss
Normal 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; }
|
||||||
15
dymini/src/pages/mine/index.tsx
Normal file
15
dymini/src/pages/mine/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
dymini/src/services/api.ts
Normal file
38
dymini/src/services/api.ts
Normal 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')
|
||||||
|
}
|
||||||
8
dymini/src/utils/config.ts
Normal file
8
dymini/src/utils/config.ts
Normal 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'
|
||||||
28
dymini/src/utils/request.ts
Normal file
28
dymini/src/utils/request.ts
Normal 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
24
dymini/tsconfig.json
Normal 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
3
dymini/types/global.d.ts
vendored
Normal 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
25
redmini/README.md
Normal 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
42
redmini/release/app.js
Normal 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
19
redmini/release/data.js
Normal 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(本包离线不可请求网络)。'
|
||||||
|
]
|
||||||
|
};
|
||||||
15
redmini/release/index.html
Normal file
15
redmini/release/index.html
Normal 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/release/styles.css
Normal file
12
redmini/release/styles.css
Normal 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}
|
||||||
40
redmini/release/xhs-bridge.js
Normal file
40
redmini/release/xhs-bridge.js
Normal 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);
|
||||||
25
redmini/scripts/assemble.py
Normal file
25
redmini/scripts/assemble.py
Normal 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
30
redmini/scripts/pack.py
Normal 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())
|
||||||
33
redmini/scripts/static_scan.py
Normal file
33
redmini/scripts/static_scan.py
Normal 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
19
redmini/src/data.js
Normal 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
42
redmini/tool/app.js
Normal 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
15
redmini/tool/index.html
Normal 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
12
redmini/tool/styles.css
Normal 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}
|
||||||
40
redmini/tool/xhs-bridge.js
Normal file
40
redmini/tool/xhs-bridge.js
Normal 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
15
wemini/README.md
Normal 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
3
wemini/babel.config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]],
|
||||||
|
}
|
||||||
5
wemini/config/dev.ts
Normal file
5
wemini/config/dev.ts
Normal 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
37
wemini/config/index.ts
Normal 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
5
wemini/config/prod.ts
Normal 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
33
wemini/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
wemini/project.config.json
Normal file
12
wemini/project.config.json
Normal 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
28
wemini/src/app.config.ts
Normal 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
5
wemini/src/app.scss
Normal 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
8
wemini/src/app.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
import './app.scss'
|
||||||
|
|
||||||
|
function App({ children }: PropsWithChildren) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
wemini/src/pages/destination/index.config.ts
Normal file
1
wemini/src/pages/destination/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '城市详情' })
|
||||||
10
wemini/src/pages/destination/index.scss
Normal file
10
wemini/src/pages/destination/index.scss
Normal 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; }
|
||||||
33
wemini/src/pages/destination/index.tsx
Normal file
33
wemini/src/pages/destination/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
wemini/src/pages/destinations/index.config.ts
Normal file
1
wemini/src/pages/destinations/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '目的地' })
|
||||||
9
wemini/src/pages/destinations/index.scss
Normal file
9
wemini/src/pages/destinations/index.scss
Normal 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; }
|
||||||
34
wemini/src/pages/destinations/index.tsx
Normal file
34
wemini/src/pages/destinations/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
wemini/src/pages/home/index.config.ts
Normal file
1
wemini/src/pages/home/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '首页' })
|
||||||
12
wemini/src/pages/home/index.scss
Normal file
12
wemini/src/pages/home/index.scss
Normal 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; }
|
||||||
32
wemini/src/pages/home/index.tsx
Normal file
32
wemini/src/pages/home/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
wemini/src/pages/meetups/index.config.ts
Normal file
1
wemini/src/pages/meetups/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '活动' })
|
||||||
9
wemini/src/pages/meetups/index.scss
Normal file
9
wemini/src/pages/meetups/index.scss
Normal 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; }
|
||||||
28
wemini/src/pages/meetups/index.tsx
Normal file
28
wemini/src/pages/meetups/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
wemini/src/pages/mine/index.config.ts
Normal file
1
wemini/src/pages/mine/index.config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default definePageConfig({ navigationBarTitleText: '我的' })
|
||||||
5
wemini/src/pages/mine/index.scss
Normal file
5
wemini/src/pages/mine/index.scss
Normal 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; }
|
||||||
15
wemini/src/pages/mine/index.tsx
Normal file
15
wemini/src/pages/mine/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
wemini/src/services/api.ts
Normal file
38
wemini/src/services/api.ts
Normal 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')
|
||||||
|
}
|
||||||
8
wemini/src/utils/config.ts
Normal file
8
wemini/src/utils/config.ts
Normal 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'
|
||||||
28
wemini/src/utils/request.ts
Normal file
28
wemini/src/utils/request.ts
Normal 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
24
wemini/tsconfig.json
Normal 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
3
wemini/types/global.d.ts
vendored
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user