From 73b0513b9cb458a40c1cea985130eaf4ab8f7a1b Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 27 Sep 2026 12:57:18 -0500 Subject: [PATCH] Add WeChat, Douyin, and offline XHS mini-program scaffolds. Co-authored-by: Cursor --- .gitignore | 7 ++++ README.md | 26 ++++++++++-- dymini/README.md | 15 +++++++ dymini/babel.config.js | 3 ++ dymini/config/dev.ts | 5 +++ dymini/config/index.ts | 37 ++++++++++++++++ dymini/config/prod.ts | 5 +++ dymini/package.json | 33 +++++++++++++++ dymini/project.config.json | 12 ++++++ dymini/project.tt.json | 10 +++++ dymini/src/app.config.ts | 28 +++++++++++++ dymini/src/app.scss | 5 +++ dymini/src/app.tsx | 8 ++++ dymini/src/pages/destination/index.config.ts | 1 + dymini/src/pages/destination/index.scss | 10 +++++ dymini/src/pages/destination/index.tsx | 33 +++++++++++++++ dymini/src/pages/destinations/index.config.ts | 1 + dymini/src/pages/destinations/index.scss | 9 ++++ dymini/src/pages/destinations/index.tsx | 34 +++++++++++++++ dymini/src/pages/home/index.config.ts | 1 + dymini/src/pages/home/index.scss | 12 ++++++ dymini/src/pages/home/index.tsx | 32 ++++++++++++++ dymini/src/pages/meetups/index.config.ts | 1 + dymini/src/pages/meetups/index.scss | 9 ++++ dymini/src/pages/meetups/index.tsx | 28 +++++++++++++ dymini/src/pages/mine/index.config.ts | 1 + dymini/src/pages/mine/index.scss | 5 +++ dymini/src/pages/mine/index.tsx | 15 +++++++ dymini/src/services/api.ts | 38 +++++++++++++++++ dymini/src/utils/config.ts | 8 ++++ dymini/src/utils/request.ts | 28 +++++++++++++ dymini/tsconfig.json | 24 +++++++++++ dymini/types/global.d.ts | 3 ++ redmini/README.md | 25 +++++++++++ redmini/release/app.js | 42 +++++++++++++++++++ redmini/release/data.js | 19 +++++++++ redmini/release/index.html | 15 +++++++ redmini/release/styles.css | 12 ++++++ redmini/release/xhs-bridge.js | 40 ++++++++++++++++++ redmini/scripts/assemble.py | 25 +++++++++++ redmini/scripts/pack.py | 30 +++++++++++++ redmini/scripts/static_scan.py | 33 +++++++++++++++ redmini/src/data.js | 19 +++++++++ redmini/tool/app.js | 42 +++++++++++++++++++ redmini/tool/index.html | 15 +++++++ redmini/tool/styles.css | 12 ++++++ redmini/tool/xhs-bridge.js | 40 ++++++++++++++++++ wemini/README.md | 15 +++++++ wemini/babel.config.js | 3 ++ wemini/config/dev.ts | 5 +++ wemini/config/index.ts | 37 ++++++++++++++++ wemini/config/prod.ts | 5 +++ wemini/package.json | 33 +++++++++++++++ wemini/project.config.json | 12 ++++++ wemini/src/app.config.ts | 28 +++++++++++++ wemini/src/app.scss | 5 +++ wemini/src/app.tsx | 8 ++++ wemini/src/pages/destination/index.config.ts | 1 + wemini/src/pages/destination/index.scss | 10 +++++ wemini/src/pages/destination/index.tsx | 33 +++++++++++++++ wemini/src/pages/destinations/index.config.ts | 1 + wemini/src/pages/destinations/index.scss | 9 ++++ wemini/src/pages/destinations/index.tsx | 34 +++++++++++++++ wemini/src/pages/home/index.config.ts | 1 + wemini/src/pages/home/index.scss | 12 ++++++ wemini/src/pages/home/index.tsx | 32 ++++++++++++++ wemini/src/pages/meetups/index.config.ts | 1 + wemini/src/pages/meetups/index.scss | 9 ++++ wemini/src/pages/meetups/index.tsx | 28 +++++++++++++ wemini/src/pages/mine/index.config.ts | 1 + wemini/src/pages/mine/index.scss | 5 +++ wemini/src/pages/mine/index.tsx | 15 +++++++ wemini/src/services/api.ts | 38 +++++++++++++++++ wemini/src/utils/config.ts | 8 ++++ wemini/src/utils/request.ts | 28 +++++++++++++ wemini/tsconfig.json | 24 +++++++++++ wemini/types/global.d.ts | 3 ++ 77 files changed, 1296 insertions(+), 4 deletions(-) create mode 100644 dymini/README.md create mode 100644 dymini/babel.config.js create mode 100644 dymini/config/dev.ts create mode 100644 dymini/config/index.ts create mode 100644 dymini/config/prod.ts create mode 100644 dymini/package.json create mode 100644 dymini/project.config.json create mode 100644 dymini/project.tt.json create mode 100644 dymini/src/app.config.ts create mode 100644 dymini/src/app.scss create mode 100644 dymini/src/app.tsx create mode 100644 dymini/src/pages/destination/index.config.ts create mode 100644 dymini/src/pages/destination/index.scss create mode 100644 dymini/src/pages/destination/index.tsx create mode 100644 dymini/src/pages/destinations/index.config.ts create mode 100644 dymini/src/pages/destinations/index.scss create mode 100644 dymini/src/pages/destinations/index.tsx create mode 100644 dymini/src/pages/home/index.config.ts create mode 100644 dymini/src/pages/home/index.scss create mode 100644 dymini/src/pages/home/index.tsx create mode 100644 dymini/src/pages/meetups/index.config.ts create mode 100644 dymini/src/pages/meetups/index.scss create mode 100644 dymini/src/pages/meetups/index.tsx create mode 100644 dymini/src/pages/mine/index.config.ts create mode 100644 dymini/src/pages/mine/index.scss create mode 100644 dymini/src/pages/mine/index.tsx create mode 100644 dymini/src/services/api.ts create mode 100644 dymini/src/utils/config.ts create mode 100644 dymini/src/utils/request.ts create mode 100644 dymini/tsconfig.json create mode 100644 dymini/types/global.d.ts create mode 100644 redmini/README.md create mode 100644 redmini/release/app.js create mode 100644 redmini/release/data.js create mode 100644 redmini/release/index.html create mode 100644 redmini/release/styles.css create mode 100644 redmini/release/xhs-bridge.js create mode 100644 redmini/scripts/assemble.py create mode 100644 redmini/scripts/pack.py create mode 100644 redmini/scripts/static_scan.py create mode 100644 redmini/src/data.js create mode 100644 redmini/tool/app.js create mode 100644 redmini/tool/index.html create mode 100644 redmini/tool/styles.css create mode 100644 redmini/tool/xhs-bridge.js create mode 100644 wemini/README.md create mode 100644 wemini/babel.config.js create mode 100644 wemini/config/dev.ts create mode 100644 wemini/config/index.ts create mode 100644 wemini/config/prod.ts create mode 100644 wemini/package.json create mode 100644 wemini/project.config.json create mode 100644 wemini/src/app.config.ts create mode 100644 wemini/src/app.scss create mode 100644 wemini/src/app.tsx create mode 100644 wemini/src/pages/destination/index.config.ts create mode 100644 wemini/src/pages/destination/index.scss create mode 100644 wemini/src/pages/destination/index.tsx create mode 100644 wemini/src/pages/destinations/index.config.ts create mode 100644 wemini/src/pages/destinations/index.scss create mode 100644 wemini/src/pages/destinations/index.tsx create mode 100644 wemini/src/pages/home/index.config.ts create mode 100644 wemini/src/pages/home/index.scss create mode 100644 wemini/src/pages/home/index.tsx create mode 100644 wemini/src/pages/meetups/index.config.ts create mode 100644 wemini/src/pages/meetups/index.scss create mode 100644 wemini/src/pages/meetups/index.tsx create mode 100644 wemini/src/pages/mine/index.config.ts create mode 100644 wemini/src/pages/mine/index.scss create mode 100644 wemini/src/pages/mine/index.tsx create mode 100644 wemini/src/services/api.ts create mode 100644 wemini/src/utils/config.ts create mode 100644 wemini/src/utils/request.ts create mode 100644 wemini/tsconfig.json create mode 100644 wemini/types/global.d.ts diff --git a/.gitignore b/.gitignore index 0bee5c4..51b842c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index f106a41..bce37c5 100644 --- a/README.md +++ b/README.md @@ -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 + 增量构建重启 ``` diff --git a/dymini/README.md b/dymini/README.md new file mode 100644 index 0000000..df24dc2 --- /dev/null +++ b/dymini/README.md @@ -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` 并配置合法域名。 diff --git a/dymini/babel.config.js b/dymini/babel.config.js new file mode 100644 index 0000000..384df21 --- /dev/null +++ b/dymini/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]], +} diff --git a/dymini/config/dev.ts b/dymini/config/dev.ts new file mode 100644 index 0000000..9cfeea1 --- /dev/null +++ b/dymini/config/dev.ts @@ -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'), + }, +} diff --git a/dymini/config/index.ts b/dymini/config/index.ts new file mode 100644 index 0000000..e547547 --- /dev/null +++ b/dymini/config/index.ts @@ -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) +}) diff --git a/dymini/config/prod.ts b/dymini/config/prod.ts new file mode 100644 index 0000000..78655f4 --- /dev/null +++ b/dymini/config/prod.ts @@ -0,0 +1,5 @@ +export default { + defineConstants: { + TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'), + }, +} diff --git a/dymini/package.json b/dymini/package.json new file mode 100644 index 0000000..9763d97 --- /dev/null +++ b/dymini/package.json @@ -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" + } +} diff --git a/dymini/project.config.json b/dymini/project.config.json new file mode 100644 index 0000000..2e2bfe7 --- /dev/null +++ b/dymini/project.config.json @@ -0,0 +1,12 @@ +{ + "miniprogramRoot": "dist/", + "projectname": "nomadweb-dymini", + "description": "nomadro 抖音小程序", + "appid": "ttXXXXXXXX", + "setting": { + "urlCheck": false, + "es6": false, + "minified": false + }, + "compileType": "microApp" +} diff --git a/dymini/project.tt.json b/dymini/project.tt.json new file mode 100644 index 0000000..e0a538d --- /dev/null +++ b/dymini/project.tt.json @@ -0,0 +1,10 @@ +{ + "miniprogramRoot": "./", + "projectname": "nomadweb-dymini", + "description": "nomadro 抖音小程序", + "appid": "ttXXXXXXXX", + "setting": { + "urlCheck": false + }, + "compileType": "microApp" +} diff --git a/dymini/src/app.config.ts b/dymini/src/app.config.ts new file mode 100644 index 0000000..6424068 --- /dev/null +++ b/dymini/src/app.config.ts @@ -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: '我的' }, + ], + }, +}) diff --git a/dymini/src/app.scss b/dymini/src/app.scss new file mode 100644 index 0000000..596f8be --- /dev/null +++ b/dymini/src/app.scss @@ -0,0 +1,5 @@ +page { + background: #0c0b12; + color: #f4f0ea; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} diff --git a/dymini/src/app.tsx b/dymini/src/app.tsx new file mode 100644 index 0000000..29c83c0 --- /dev/null +++ b/dymini/src/app.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react' +import './app.scss' + +function App({ children }: PropsWithChildren) { + return children +} + +export default App diff --git a/dymini/src/pages/destination/index.config.ts b/dymini/src/pages/destination/index.config.ts new file mode 100644 index 0000000..4d9e13e --- /dev/null +++ b/dymini/src/pages/destination/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '城市详情' }) diff --git a/dymini/src/pages/destination/index.scss b/dymini/src/pages/destination/index.scss new file mode 100644 index 0000000..63dcf93 --- /dev/null +++ b/dymini/src/pages/destination/index.scss @@ -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; } diff --git a/dymini/src/pages/destination/index.tsx b/dymini/src/pages/destination/index.tsx new file mode 100644 index 0000000..bebe9af --- /dev/null +++ b/dymini/src/pages/destination/index.tsx @@ -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(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 {err} + if (!d) return 加载中… + + return ( + + {d.name} + {d.country} + {d.tagline} + + 月生活费¥{d.cost ?? '—'} + 网速{d.speed ?? '—'} Mbps + 评分{d.rating ?? '—'} + 气候{d.climate || '—'} + + + ) +} diff --git a/dymini/src/pages/destinations/index.config.ts b/dymini/src/pages/destinations/index.config.ts new file mode 100644 index 0000000..9bcedd1 --- /dev/null +++ b/dymini/src/pages/destinations/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '目的地' }) diff --git a/dymini/src/pages/destinations/index.scss b/dymini/src/pages/destinations/index.scss new file mode 100644 index 0000000..dd59d67 --- /dev/null +++ b/dymini/src/pages/destinations/index.scss @@ -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; } diff --git a/dymini/src/pages/destinations/index.tsx b/dymini/src/pages/destinations/index.tsx new file mode 100644 index 0000000..c972245 --- /dev/null +++ b/dymini/src/pages/destinations/index.tsx @@ -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([]) + const [err, setErr] = useState('') + + useLoad(() => { + fetchDestinations() + .then(setItems) + .catch((e) => setErr(e.message || '加载失败')) + }) + + return ( + + 热门目的地 + {err ? {err} : null} + {items.map((d) => ( + Taro.navigateTo({ url: `/pages/destination/index?slug=${d.slug}` })} + > + {d.name} + {d.country || ''} · ¥{d.cost ?? '—'} /月 · ★{d.rating ?? '—'} + {d.tagline || d.climate || ''} + + ))} + + ) +} diff --git a/dymini/src/pages/home/index.config.ts b/dymini/src/pages/home/index.config.ts new file mode 100644 index 0000000..c2a0a60 --- /dev/null +++ b/dymini/src/pages/home/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '首页' }) diff --git a/dymini/src/pages/home/index.scss b/dymini/src/pages/home/index.scss new file mode 100644 index 0000000..b603c17 --- /dev/null +++ b/dymini/src/pages/home/index.scss @@ -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; } diff --git a/dymini/src/pages/home/index.tsx b/dymini/src/pages/home/index.tsx new file mode 100644 index 0000000..f49ad3d --- /dev/null +++ b/dymini/src/pages/home/index.tsx @@ -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 ( + + nomadro + 用一行代码环游世界 + {err ? {err} : null} + + {stats?.destinations ?? '—'}目的地 + {stats?.members ?? '—'}游民 + {stats?.meetups ?? '—'}活动 + + + + 网页版 nomadweb.nomadro.com + + ) +} diff --git a/dymini/src/pages/meetups/index.config.ts b/dymini/src/pages/meetups/index.config.ts new file mode 100644 index 0000000..4f89c64 --- /dev/null +++ b/dymini/src/pages/meetups/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '活动' }) diff --git a/dymini/src/pages/meetups/index.scss b/dymini/src/pages/meetups/index.scss new file mode 100644 index 0000000..dd59d67 --- /dev/null +++ b/dymini/src/pages/meetups/index.scss @@ -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; } diff --git a/dymini/src/pages/meetups/index.tsx b/dymini/src/pages/meetups/index.tsx new file mode 100644 index 0000000..838f3e1 --- /dev/null +++ b/dymini/src/pages/meetups/index.tsx @@ -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([]) + const [err, setErr] = useState('') + + useLoad(() => { + fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败')) + }) + + return ( + + 游民活动 + {err ? {err} : null} + {items.map((m) => ( + + {m.title} + {m.city || '全球'} · {m.date || '待定'} · {m.attendees ?? 0} 人 + + ))} + {!err && items.length === 0 ? 暂无活动 : null} + + ) +} diff --git a/dymini/src/pages/mine/index.config.ts b/dymini/src/pages/mine/index.config.ts new file mode 100644 index 0000000..91f1b47 --- /dev/null +++ b/dymini/src/pages/mine/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '我的' }) diff --git a/dymini/src/pages/mine/index.scss b/dymini/src/pages/mine/index.scss new file mode 100644 index 0000000..a874399 --- /dev/null +++ b/dymini/src/pages/mine/index.scss @@ -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; } diff --git a/dymini/src/pages/mine/index.tsx b/dymini/src/pages/mine/index.tsx new file mode 100644 index 0000000..2763661 --- /dev/null +++ b/dymini/src/pages/mine/index.tsx @@ -0,0 +1,15 @@ +import { View, Text, Button } from '@tarojs/components' +import Taro from '@tarojs/taro' +import './index.scss' + +export default function MinePage() { + return ( + + 我的 + 登录与会员请使用网页版完整能力 + + + ) +} diff --git a/dymini/src/services/api.ts b/dymini/src/services/api.ts new file mode 100644 index 0000000..50f03ea --- /dev/null +++ b/dymini/src/services/api.ts @@ -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('/destinations') +} + +export function fetchDestination(slug: string) { + return request(`/destinations/${encodeURIComponent(slug)}`) +} + +export function fetchMeetups() { + return request('/meetups') +} + +export function fetchStats() { + return request<{ destinations?: number; members?: number; meetups?: number }>('/stats') +} diff --git a/dymini/src/utils/config.ts b/dymini/src/utils/config.ts new file mode 100644 index 0000000..d442988 --- /dev/null +++ b/dymini/src/utils/config.ts @@ -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' diff --git a/dymini/src/utils/request.ts b/dymini/src/utils/request.ts new file mode 100644 index 0000000..a38bbc6 --- /dev/null +++ b/dymini/src/utils/request.ts @@ -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 + header?: Record +} + +export async function request(options: Opts): Promise { + 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 +} diff --git a/dymini/tsconfig.json b/dymini/tsconfig.json new file mode 100644 index 0000000..596bcf8 --- /dev/null +++ b/dymini/tsconfig.json @@ -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" + ] +} diff --git a/dymini/types/global.d.ts b/dymini/types/global.d.ts new file mode 100644 index 0000000..1123c07 --- /dev/null +++ b/dymini/types/global.d.ts @@ -0,0 +1,3 @@ +declare function defineAppConfig(config: Record): Record +declare function definePageConfig(config: Record): Record +declare const TARO_APP_API: string diff --git a/redmini/README.md b/redmini/README.md new file mode 100644 index 0000000..f0bcaf6 --- /dev/null +++ b/redmini/README.md @@ -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 | diff --git a/redmini/release/app.js b/redmini/release/app.js new file mode 100644 index 0000000..e22fbb2 --- /dev/null +++ b/redmini/release/app.js @@ -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 ( + '
' + + '
' + d.name + '
' + + '
' + (d.country || '') + ' · ¥' + (d.cost || '—') + '/月 · ★' + (d.rating || '—') + '
' + + '
' + (d.tagline || '') + '
' + + '
' + (marked || '点按收藏(仅本地)') + '
' + + '
' + ); + }).join(''); + var tips = (data.tips || []).map(function (t) { return '
  • ' + t + '
  • '; }).join(''); + root.innerHTML = + '

    ' + (data.brand || 'nomadro') + '

    ' + + '

    ' + (data.tagline || '') + '

    ' + + '

    ' + (data.note || '') + '

    ' + + cards + + '

    离线提示

      ' + tips + '
    '; + + 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 : []); + }); +})(); diff --git a/redmini/release/data.js b/redmini/release/data.js new file mode 100644 index 0000000..173b82e --- /dev/null +++ b/redmini/release/data.js @@ -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(本包离线不可请求网络)。' + ] +}; diff --git a/redmini/release/index.html b/redmini/release/index.html new file mode 100644 index 0000000..aca5e88 --- /dev/null +++ b/redmini/release/index.html @@ -0,0 +1,15 @@ + + + + + + nomadro · 离线目的地 + + + +
    + + + + + diff --git a/redmini/release/styles.css b/redmini/release/styles.css new file mode 100644 index 0000000..466149d --- /dev/null +++ b/redmini/release/styles.css @@ -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} diff --git a/redmini/release/xhs-bridge.js b/redmini/release/xhs-bridge.js new file mode 100644 index 0000000..b2673df --- /dev/null +++ b/redmini/release/xhs-bridge.js @@ -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); diff --git a/redmini/scripts/assemble.py b/redmini/scripts/assemble.py new file mode 100644 index 0000000..6ab24ea --- /dev/null +++ b/redmini/scripts/assemble.py @@ -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()) diff --git a/redmini/scripts/pack.py b/redmini/scripts/pack.py new file mode 100644 index 0000000..d8cdd5d --- /dev/null +++ b/redmini/scripts/pack.py @@ -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()) diff --git a/redmini/scripts/static_scan.py b/redmini/scripts/static_scan.py new file mode 100644 index 0000000..d46105d --- /dev/null +++ b/redmini/scripts/static_scan.py @@ -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()) diff --git a/redmini/src/data.js b/redmini/src/data.js new file mode 100644 index 0000000..173b82e --- /dev/null +++ b/redmini/src/data.js @@ -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(本包离线不可请求网络)。' + ] +}; diff --git a/redmini/tool/app.js b/redmini/tool/app.js new file mode 100644 index 0000000..e22fbb2 --- /dev/null +++ b/redmini/tool/app.js @@ -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 ( + '
    ' + + '
    ' + d.name + '
    ' + + '
    ' + (d.country || '') + ' · ¥' + (d.cost || '—') + '/月 · ★' + (d.rating || '—') + '
    ' + + '
    ' + (d.tagline || '') + '
    ' + + '
    ' + (marked || '点按收藏(仅本地)') + '
    ' + + '
    ' + ); + }).join(''); + var tips = (data.tips || []).map(function (t) { return '
  • ' + t + '
  • '; }).join(''); + root.innerHTML = + '

    ' + (data.brand || 'nomadro') + '

    ' + + '

    ' + (data.tagline || '') + '

    ' + + '

    ' + (data.note || '') + '

    ' + + cards + + '

    离线提示

      ' + tips + '
    '; + + 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 : []); + }); +})(); diff --git a/redmini/tool/index.html b/redmini/tool/index.html new file mode 100644 index 0000000..aca5e88 --- /dev/null +++ b/redmini/tool/index.html @@ -0,0 +1,15 @@ + + + + + + nomadro · 离线目的地 + + + +
    + + + + + diff --git a/redmini/tool/styles.css b/redmini/tool/styles.css new file mode 100644 index 0000000..466149d --- /dev/null +++ b/redmini/tool/styles.css @@ -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} diff --git a/redmini/tool/xhs-bridge.js b/redmini/tool/xhs-bridge.js new file mode 100644 index 0000000..b2673df --- /dev/null +++ b/redmini/tool/xhs-bridge.js @@ -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); diff --git a/wemini/README.md b/wemini/README.md new file mode 100644 index 0000000..068fbb1 --- /dev/null +++ b/wemini/README.md @@ -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 域名。 diff --git a/wemini/babel.config.js b/wemini/babel.config.js new file mode 100644 index 0000000..384df21 --- /dev/null +++ b/wemini/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [['taro', { framework: 'react', ts: true, compiler: 'webpack5' }]], +} diff --git a/wemini/config/dev.ts b/wemini/config/dev.ts new file mode 100644 index 0000000..9cfeea1 --- /dev/null +++ b/wemini/config/dev.ts @@ -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'), + }, +} diff --git a/wemini/config/index.ts b/wemini/config/index.ts new file mode 100644 index 0000000..bc5f8f9 --- /dev/null +++ b/wemini/config/index.ts @@ -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) +}) diff --git a/wemini/config/prod.ts b/wemini/config/prod.ts new file mode 100644 index 0000000..78655f4 --- /dev/null +++ b/wemini/config/prod.ts @@ -0,0 +1,5 @@ +export default { + defineConstants: { + TARO_APP_API: JSON.stringify(process.env.TARO_APP_API || 'https://nomadweb.nomadro.com/api/v1'), + }, +} diff --git a/wemini/package.json b/wemini/package.json new file mode 100644 index 0000000..488885a --- /dev/null +++ b/wemini/package.json @@ -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" + } +} diff --git a/wemini/project.config.json b/wemini/project.config.json new file mode 100644 index 0000000..a2f49ee --- /dev/null +++ b/wemini/project.config.json @@ -0,0 +1,12 @@ +{ + "miniprogramRoot": "dist/", + "projectname": "nomadweb-wemini", + "description": "nomadro 微信小程序", + "appid": "touristappid", + "setting": { + "urlCheck": false, + "es6": true, + "minified": true + }, + "compileType": "miniprogram" +} diff --git a/wemini/src/app.config.ts b/wemini/src/app.config.ts new file mode 100644 index 0000000..6424068 --- /dev/null +++ b/wemini/src/app.config.ts @@ -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: '我的' }, + ], + }, +}) diff --git a/wemini/src/app.scss b/wemini/src/app.scss new file mode 100644 index 0000000..596f8be --- /dev/null +++ b/wemini/src/app.scss @@ -0,0 +1,5 @@ +page { + background: #0c0b12; + color: #f4f0ea; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} diff --git a/wemini/src/app.tsx b/wemini/src/app.tsx new file mode 100644 index 0000000..29c83c0 --- /dev/null +++ b/wemini/src/app.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react' +import './app.scss' + +function App({ children }: PropsWithChildren) { + return children +} + +export default App diff --git a/wemini/src/pages/destination/index.config.ts b/wemini/src/pages/destination/index.config.ts new file mode 100644 index 0000000..4d9e13e --- /dev/null +++ b/wemini/src/pages/destination/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '城市详情' }) diff --git a/wemini/src/pages/destination/index.scss b/wemini/src/pages/destination/index.scss new file mode 100644 index 0000000..63dcf93 --- /dev/null +++ b/wemini/src/pages/destination/index.scss @@ -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; } diff --git a/wemini/src/pages/destination/index.tsx b/wemini/src/pages/destination/index.tsx new file mode 100644 index 0000000..bebe9af --- /dev/null +++ b/wemini/src/pages/destination/index.tsx @@ -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(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 {err} + if (!d) return 加载中… + + return ( + + {d.name} + {d.country} + {d.tagline} + + 月生活费¥{d.cost ?? '—'} + 网速{d.speed ?? '—'} Mbps + 评分{d.rating ?? '—'} + 气候{d.climate || '—'} + + + ) +} diff --git a/wemini/src/pages/destinations/index.config.ts b/wemini/src/pages/destinations/index.config.ts new file mode 100644 index 0000000..9bcedd1 --- /dev/null +++ b/wemini/src/pages/destinations/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '目的地' }) diff --git a/wemini/src/pages/destinations/index.scss b/wemini/src/pages/destinations/index.scss new file mode 100644 index 0000000..dd59d67 --- /dev/null +++ b/wemini/src/pages/destinations/index.scss @@ -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; } diff --git a/wemini/src/pages/destinations/index.tsx b/wemini/src/pages/destinations/index.tsx new file mode 100644 index 0000000..c972245 --- /dev/null +++ b/wemini/src/pages/destinations/index.tsx @@ -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([]) + const [err, setErr] = useState('') + + useLoad(() => { + fetchDestinations() + .then(setItems) + .catch((e) => setErr(e.message || '加载失败')) + }) + + return ( + + 热门目的地 + {err ? {err} : null} + {items.map((d) => ( + Taro.navigateTo({ url: `/pages/destination/index?slug=${d.slug}` })} + > + {d.name} + {d.country || ''} · ¥{d.cost ?? '—'} /月 · ★{d.rating ?? '—'} + {d.tagline || d.climate || ''} + + ))} + + ) +} diff --git a/wemini/src/pages/home/index.config.ts b/wemini/src/pages/home/index.config.ts new file mode 100644 index 0000000..c2a0a60 --- /dev/null +++ b/wemini/src/pages/home/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '首页' }) diff --git a/wemini/src/pages/home/index.scss b/wemini/src/pages/home/index.scss new file mode 100644 index 0000000..b603c17 --- /dev/null +++ b/wemini/src/pages/home/index.scss @@ -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; } diff --git a/wemini/src/pages/home/index.tsx b/wemini/src/pages/home/index.tsx new file mode 100644 index 0000000..f49ad3d --- /dev/null +++ b/wemini/src/pages/home/index.tsx @@ -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 ( + + nomadro + 用一行代码环游世界 + {err ? {err} : null} + + {stats?.destinations ?? '—'}目的地 + {stats?.members ?? '—'}游民 + {stats?.meetups ?? '—'}活动 + + + + 网页版 nomadweb.nomadro.com + + ) +} diff --git a/wemini/src/pages/meetups/index.config.ts b/wemini/src/pages/meetups/index.config.ts new file mode 100644 index 0000000..4f89c64 --- /dev/null +++ b/wemini/src/pages/meetups/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '活动' }) diff --git a/wemini/src/pages/meetups/index.scss b/wemini/src/pages/meetups/index.scss new file mode 100644 index 0000000..dd59d67 --- /dev/null +++ b/wemini/src/pages/meetups/index.scss @@ -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; } diff --git a/wemini/src/pages/meetups/index.tsx b/wemini/src/pages/meetups/index.tsx new file mode 100644 index 0000000..838f3e1 --- /dev/null +++ b/wemini/src/pages/meetups/index.tsx @@ -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([]) + const [err, setErr] = useState('') + + useLoad(() => { + fetchMeetups().then(setItems).catch((e) => setErr(e.message || '加载失败')) + }) + + return ( + + 游民活动 + {err ? {err} : null} + {items.map((m) => ( + + {m.title} + {m.city || '全球'} · {m.date || '待定'} · {m.attendees ?? 0} 人 + + ))} + {!err && items.length === 0 ? 暂无活动 : null} + + ) +} diff --git a/wemini/src/pages/mine/index.config.ts b/wemini/src/pages/mine/index.config.ts new file mode 100644 index 0000000..91f1b47 --- /dev/null +++ b/wemini/src/pages/mine/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '我的' }) diff --git a/wemini/src/pages/mine/index.scss b/wemini/src/pages/mine/index.scss new file mode 100644 index 0000000..a874399 --- /dev/null +++ b/wemini/src/pages/mine/index.scss @@ -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; } diff --git a/wemini/src/pages/mine/index.tsx b/wemini/src/pages/mine/index.tsx new file mode 100644 index 0000000..2763661 --- /dev/null +++ b/wemini/src/pages/mine/index.tsx @@ -0,0 +1,15 @@ +import { View, Text, Button } from '@tarojs/components' +import Taro from '@tarojs/taro' +import './index.scss' + +export default function MinePage() { + return ( + + 我的 + 登录与会员请使用网页版完整能力 + + + ) +} diff --git a/wemini/src/services/api.ts b/wemini/src/services/api.ts new file mode 100644 index 0000000..50f03ea --- /dev/null +++ b/wemini/src/services/api.ts @@ -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('/destinations') +} + +export function fetchDestination(slug: string) { + return request(`/destinations/${encodeURIComponent(slug)}`) +} + +export function fetchMeetups() { + return request('/meetups') +} + +export function fetchStats() { + return request<{ destinations?: number; members?: number; meetups?: number }>('/stats') +} diff --git a/wemini/src/utils/config.ts b/wemini/src/utils/config.ts new file mode 100644 index 0000000..58d2105 --- /dev/null +++ b/wemini/src/utils/config.ts @@ -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' diff --git a/wemini/src/utils/request.ts b/wemini/src/utils/request.ts new file mode 100644 index 0000000..a38bbc6 --- /dev/null +++ b/wemini/src/utils/request.ts @@ -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 + header?: Record +} + +export async function request(options: Opts): Promise { + 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 +} diff --git a/wemini/tsconfig.json b/wemini/tsconfig.json new file mode 100644 index 0000000..596bcf8 --- /dev/null +++ b/wemini/tsconfig.json @@ -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" + ] +} diff --git a/wemini/types/global.d.ts b/wemini/types/global.d.ts new file mode 100644 index 0000000..1123c07 --- /dev/null +++ b/wemini/types/global.d.ts @@ -0,0 +1,3 @@ +declare function defineAppConfig(config: Record): Record +declare function definePageConfig(config: Record): Record +declare const TARO_APP_API: string