feat: native progress query, resubmit and map picker

Replace the WebView shell with a native check page that accepts H5 launch params, queries orders, resubmits materials with Qiniu uploads, and picks map locations on a WeChat Map with reverse geocoding.
This commit is contained in:
zhangjianjun 2026-08-18 17:18:45 +08:00
parent 15b047dfdd
commit 3a42f65c5a
53 changed files with 3798 additions and 1110 deletions

View File

@ -1,2 +1,3 @@
# 配置文档参考 https://taro-docs.jd.com/docs/next/env-mode-config TARO_APP_API_ORIGIN=https://corp-test.batiao8.com
TARO_APP_ID="wx6d4f6f29c41aff93" TARO_APP_API_HOST=nb.batiao8.com
TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198

View File

@ -1,2 +1,3 @@
# TARO_APP_ID="生产环境下的小程序 AppID" TARO_APP_API_ORIGIN=https://nb.zuom8.cn
TARO_APP_ID="wx6d4f6f29c41aff93" TARO_APP_API_HOST=nb.zuom8.cn
TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198

View File

@ -23,17 +23,22 @@ Other targets follow `pnpm dev:<platform>` / `pnpm build:<platform>` (`alipay`,
## Architecture ## Architecture
The app is a thin native shell around a business H5, with a native payment bridge. Two pages, registered in `src/app.config.ts`: Native mini-program for map-label progress query. Pages in `src/app.config.ts`:
**1. `pages/index/index.tsx` — auth + WebView host.** On load it calls `Taro.login()` to get the WeChat `code`, then renders a full-screen `<WebView>` pointing at the business H5 (`http://niubsw.com/...?mpCode=<code>`). All business UI lives in that remote H5, not in this repo. **`pages/index/index` — 进度查询.** Accepts H5 jump query `token` / `host` / `package` / `scene` (see `corp-h5` `weixinMiniProgram.ts`). Queries `GET /api/h5/order/phone`, resubmits with `PUT /api/h5/order`, uploads via `GET /api/presign` + Qiniu.
**2. `pages/pay/index.tsx` — native payment bridge.** The H5 cannot invoke WeChat JSAPI pay while running inside the mini-program WebView, so it hands off to this native page. The handoff contract: **`pages/map/index` — 微信原生地图选点.** Tap / locate / `chooseLocation` search, returns GCJ-02 `lng,lat`.
- The H5 detects it's inside the mini-program and calls `wxSdk.miniProgram.navigateTo({ url: '/pages/pay/index?order=<b64>&formInfo=<b64>' })`.
- Params are **base64-encoded JSON**, produced H5-side by `utf8ToBase64(v) = btoa(unescape(encodeURIComponent(JSON.stringify(v))))`.
- `order` already contains the complete WeChat JSAPI fields (`timeStamp`/`nonceStr`/`package`/`signType`/`paySign`). The page decodes it, auto-fires `Taro.requestPayment`, then `navigateBack()`s to the H5 on success. No backend call is made here.
- `temp.txt` (repo root) holds the H5-side snippet documenting this contract — keep the two sides in sync.
**`src/utils/base64.ts`** is the strict inverse of the H5 encoder. It hand-rolls a base64 decoder because **weapp has no global `atob`**, and normalizes `+`→space mangling from URL routing. Reuse `decodeOrderParam<T>(raw)` for any future base64 route params. ### Session and request
- API origin comes from `TARO_APP_API_ORIGIN`: `.env.development``http://corp-test.batiao8.com`, `.env.production``https://nb.zuom8.cn`.
- `x-host` prefers the H5 jump `host`, then `TARO_APP_API_HOST`.
- Map reverse geocode uses `TARO_APP_TIANDITU_KEY` against `api.tianditu.gov.cn` (add this request domain in WeChat admin).
- Missing launch query fields fall back to `DEFAULT_LAUNCH_SCHEME` in `src/lib/launch.ts` (`scene=/h/KZ9Q`, `host=nb.batiao8.com`, `package=10044`, `phone=13800138000`).
- No token: `GET /api/user/config` then keep the returned guest token. Then `GET /api/h5/corp` for package/config.
- Shared client: `src/lib/request.ts` (same MD5 sign + AES decrypt as H5). Platform header is `wx-mp`.
Do not reintroduce a business H5 WebView unless explicitly requested.
## Conventions ## Conventions

View File

@ -29,7 +29,8 @@
"dev:rn": "npm run build:rn -- --watch", "dev:rn": "npm run build:rn -- --watch",
"dev:qq": "npm run build:qq -- --watch", "dev:qq": "npm run build:qq -- --watch",
"dev:jd": "npm run build:jd -- --watch", "dev:jd": "npm run build:jd -- --watch",
"dev:harmony-hybrid": "npm run build:harmony-hybrid -- --watch" "dev:harmony-hybrid": "npm run build:harmony-hybrid -- --watch",
"test": "vitest run"
}, },
"browserslist": { "browserslist": {
"development": [ "development": [
@ -47,48 +48,51 @@
"@babel/runtime": "^7.24.4", "@babel/runtime": "^7.24.4",
"@tarojs/components": "4.2.0", "@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0", "@tarojs/helper": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0", "@tarojs/plugin-framework-react": "4.2.0",
"@tarojs/plugin-platform-alipay": "4.2.0", "@tarojs/plugin-platform-alipay": "4.2.0",
"@tarojs/plugin-platform-tt": "4.2.0",
"@tarojs/plugin-platform-swan": "4.2.0",
"@tarojs/plugin-platform-jd": "4.2.0",
"@tarojs/plugin-platform-qq": "4.2.0",
"@tarojs/plugin-platform-h5": "4.2.0", "@tarojs/plugin-platform-h5": "4.2.0",
"@tarojs/plugin-platform-harmony-hybrid": "4.2.0", "@tarojs/plugin-platform-harmony-hybrid": "4.2.0",
"@tarojs/plugin-platform-jd": "4.2.0",
"@tarojs/plugin-platform-qq": "4.2.0",
"@tarojs/plugin-platform-swan": "4.2.0",
"@tarojs/plugin-platform-tt": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/react": "4.2.0",
"@tarojs/runtime": "4.2.0", "@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0", "@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0", "@tarojs/taro": "4.2.0",
"@tarojs/plugin-framework-react": "4.2.0", "crypto-js": "^4.2.0",
"@tarojs/react": "4.2.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0"
"react": "^18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tarojs/plugin-generator": "4.2.0",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"lint-staged": "^16.1.2",
"husky": "^9.1.7",
"stylelint-config-standard": "^38.0.0",
"@babel/core": "^7.24.4", "@babel/core": "^7.24.4",
"@babel/plugin-transform-class-properties": "7.25.9", "@babel/plugin-transform-class-properties": "7.25.9",
"@tarojs/cli": "4.2.0",
"@tarojs/vite-runner": "4.2.0",
"babel-preset-taro": "4.2.0",
"eslint-config-taro": "4.2.0",
"eslint": "^8.57.0",
"stylelint": "^16.4.0",
"terser": "^5.30.4",
"vite": "^4.2.0",
"@babel/preset-react": "^7.24.1", "@babel/preset-react": "^7.24.1",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@tarojs/cli": "4.2.0",
"@tarojs/plugin-generator": "4.2.0",
"@tarojs/vite-runner": "4.2.0",
"@types/crypto-js": "^4.2.2",
"@types/minimatch": "^5",
"@types/react": "^18.0.0", "@types/react": "^18.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"babel-preset-taro": "4.2.0",
"eslint": "^8.57.0",
"eslint-config-taro": "4.2.0",
"eslint-plugin-react": "^7.34.1", "eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.4.0", "eslint-plugin-react-hooks": "^4.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.2",
"postcss": "^8.5.6",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"sass": "^1.75.0", "sass": "^1.75.0",
"stylelint": "^16.4.0",
"stylelint-config-standard": "^38.0.0",
"terser": "^5.30.4",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"postcss": "^8.5.6", "vite": "^4.2.0",
"@types/minimatch": "^5" "vitest": "^2.1.9"
} }
} }

View File

@ -56,6 +56,9 @@ importers:
'@tarojs/taro': '@tarojs/taro':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0) version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)
crypto-js:
specifier: ^4.2.0
version: 4.2.0
react: react:
specifier: ^18.0.0 specifier: ^18.0.0
version: 18.3.1 version: 18.3.1
@ -87,6 +90,9 @@ importers:
'@tarojs/vite-runner': '@tarojs/vite-runner':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(jiti@2.6.1)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@4.5.14(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)) version: 4.2.0(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(jiti@2.6.1)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@4.5.14(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0))
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
'@types/minimatch': '@types/minimatch':
specifier: ^5 specifier: ^5
version: 5.1.2 version: 5.1.2
@ -141,6 +147,9 @@ importers:
vite: vite:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.5.14(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0) version: 4.5.14(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)
vitest:
specifier: ^2.1.9
version: 2.1.9(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)
packages: packages:
@ -1253,6 +1262,13 @@ packages:
'@keyv/serialize@1.1.1': '@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/triples@1.2.0': '@napi-rs/triples@1.2.0':
resolution: {integrity: sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA==} resolution: {integrity: sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA==}
@ -1425,6 +1441,144 @@ packages:
rollup: rollup:
optional: true optional: true
'@rollup/rollup-android-arm-eabi@4.62.4':
resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.62.4':
resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.62.4':
resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.62.4':
resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.62.4':
resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.62.4':
resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.62.4':
resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.4':
resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.4':
resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.4':
resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.4':
resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.4':
resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.4':
resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.4':
resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.4':
resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.4':
resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.4':
resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.4':
resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.4':
resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.4':
resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.62.4':
resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.62.4':
resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.62.4':
resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.62.4':
resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.62.4':
resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==}
cpu: [x64]
os: [win32]
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -1927,6 +2081,9 @@ packages:
'@types/conventional-commits-parser@5.0.2': '@types/conventional-commits-parser@5.0.2':
resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==} resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/debug@4.1.13': '@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@ -2056,6 +2213,35 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
'@vitest/mocker@2.1.9':
resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@2.1.9':
resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
'@vitest/runner@2.1.9':
resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
'@vitest/snapshot@2.1.9':
resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
'@vitest/spy@2.1.9':
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
JSONStream@1.3.5: JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
hasBin: true hasBin: true
@ -2171,6 +2357,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
astral-regex@2.0.0: astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2326,6 +2516,10 @@ packages:
buffer@5.7.1: buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
cacheable-request@2.1.4: cacheable-request@2.1.4:
resolution: {integrity: sha512-vag0O2LKZ/najSoUwDbVlnlCFvhBE/7mGTY2B5FgCBDcRD+oVV1HYTOwM6JZfMg/hIcM6IwnTZ1uQQL5/X3xIQ==} resolution: {integrity: sha512-vag0O2LKZ/najSoUwDbVlnlCFvhBE/7mGTY2B5FgCBDcRD+oVV1HYTOwM6JZfMg/hIcM6IwnTZ1uQQL5/X3xIQ==}
@ -2368,6 +2562,10 @@ packages:
resolution: {integrity: sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==} resolution: {integrity: sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==}
engines: {node: '>=4'} engines: {node: '>=4'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
chalk@3.0.0: chalk@3.0.0:
resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2386,6 +2584,10 @@ packages:
chardet@2.1.1: chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@ -2547,6 +2749,10 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
css-functions-list@3.3.3: css-functions-list@3.3.3:
resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -2636,6 +2842,10 @@ packages:
babel-plugin-macros: babel-plugin-macros:
optional: true optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-extend@0.6.0: deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
@ -2759,6 +2969,9 @@ packages:
resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.2: es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2910,6 +3123,9 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3: esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2917,6 +3133,10 @@ packages:
eventemitter3@5.0.4: eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
ext-list@2.2.2: ext-list@2.2.2:
resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3748,6 +3968,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lower-case@1.1.4: lower-case@1.1.4:
resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==}
@ -4069,6 +4292,13 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'} engines: {node: '>=8'}
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pend@1.2.0: pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@ -4408,6 +4638,11 @@ packages:
engines: {node: '>=14.18.0', npm: '>=8.0.0'} engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
rollup@4.62.4:
resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
run-async@2.4.1: run-async@2.4.1:
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
@ -4526,6 +4761,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7: signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@ -4590,6 +4828,12 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4753,10 +4997,28 @@ packages:
tiny-case@1.0.3: tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyexec@1.2.4: tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'} engines: {node: '>=18'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@1.2.0:
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
engines: {node: '>=14.0.0'}
tinyspy@3.0.2:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'}
to-buffer@1.2.2: to-buffer@1.2.2:
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4920,6 +5182,11 @@ packages:
resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
vite-plugin-static-copy@0.17.1: vite-plugin-static-copy@0.17.1:
resolution: {integrity: sha512-9h3iaVs0bqnqZOM5YHJXGHqdC5VAVlTZ2ARYsuNpzhEJUHmFqXY7dAK4ZFpjEQ4WLFKcaN8yWbczr81n01U4sQ==} resolution: {integrity: sha512-9h3iaVs0bqnqZOM5YHJXGHqdC5VAVlTZ2ARYsuNpzhEJUHmFqXY7dAK4ZFpjEQ4WLFKcaN8yWbczr81n01U4sQ==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
@ -4954,6 +5221,62 @@ packages:
terser: terser:
optional: true optional: true
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
vitest@2.1.9:
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/node': ^18.0.0 || >=20.0.0
'@vitest/browser': 2.1.9
'@vitest/ui': 2.1.9
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
wcwidth@1.0.1: wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
@ -4993,6 +5316,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wildcard@2.0.1: wildcard@2.0.1:
resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==}
@ -6303,6 +6631,9 @@ snapshots:
'@keyv/serialize@1.1.1': {} '@keyv/serialize@1.1.1': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
'@napi-rs/triples@1.2.0': {} '@napi-rs/triples@1.2.0': {}
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
@ -6435,6 +6766,81 @@ snapshots:
optionalDependencies: optionalDependencies:
rollup: 3.30.0 rollup: 3.30.0
'@rollup/rollup-android-arm-eabi@4.62.4':
optional: true
'@rollup/rollup-android-arm64@4.62.4':
optional: true
'@rollup/rollup-darwin-arm64@4.62.4':
optional: true
'@rollup/rollup-darwin-x64@4.62.4':
optional: true
'@rollup/rollup-freebsd-arm64@4.62.4':
optional: true
'@rollup/rollup-freebsd-x64@4.62.4':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.62.4':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.62.4':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.62.4':
optional: true
'@rollup/rollup-linux-arm64-musl@4.62.4':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.62.4':
optional: true
'@rollup/rollup-linux-loong64-musl@4.62.4':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.62.4':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.62.4':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.62.4':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.62.4':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.62.4':
optional: true
'@rollup/rollup-linux-x64-gnu@4.62.4':
optional: true
'@rollup/rollup-linux-x64-musl@4.62.4':
optional: true
'@rollup/rollup-openbsd-x64@4.62.4':
optional: true
'@rollup/rollup-openharmony-arm64@4.62.4':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.62.4':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.62.4':
optional: true
'@rollup/rollup-win32-x64-gnu@4.62.4':
optional: true
'@rollup/rollup-win32-x64-msvc@4.62.4':
optional: true
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@sideway/address@4.1.5': '@sideway/address@4.1.5':
@ -7028,6 +7434,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 25.9.2 '@types/node': 25.9.2
'@types/crypto-js@4.2.2': {}
'@types/debug@4.1.13': '@types/debug@4.1.13':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
@ -7201,6 +7609,46 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0))':
dependencies:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)
'@vitest/pretty-format@2.1.9':
dependencies:
tinyrainbow: 1.2.0
'@vitest/runner@2.1.9':
dependencies:
'@vitest/utils': 2.1.9
pathe: 1.1.2
'@vitest/snapshot@2.1.9':
dependencies:
'@vitest/pretty-format': 2.1.9
magic-string: 0.30.21
pathe: 1.1.2
'@vitest/spy@2.1.9':
dependencies:
tinyspy: 3.0.2
'@vitest/utils@2.1.9':
dependencies:
'@vitest/pretty-format': 2.1.9
loupe: 3.2.1
tinyrainbow: 1.2.0
JSONStream@1.3.5: JSONStream@1.3.5:
dependencies: dependencies:
jsonparse: 1.3.1 jsonparse: 1.3.1
@ -7344,6 +7792,8 @@ snapshots:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
is-array-buffer: 3.0.5 is-array-buffer: 3.0.5
assertion-error@2.0.1: {}
astral-regex@2.0.0: {} astral-regex@2.0.0: {}
async-function@1.0.0: {} async-function@1.0.0: {}
@ -7529,6 +7979,8 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
cac@6.7.14: {}
cacheable-request@2.1.4: cacheable-request@2.1.4:
dependencies: dependencies:
clone-response: 1.0.2 clone-response: 1.0.2
@ -7601,6 +8053,14 @@ snapshots:
tunnel-agent: 0.6.0 tunnel-agent: 0.6.0
url-to-options: 1.0.1 url-to-options: 1.0.1
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
chalk@3.0.0: chalk@3.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@ -7630,6 +8090,8 @@ snapshots:
chardet@2.1.1: {} chardet@2.1.1: {}
check-error@2.1.3: {}
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@ -7801,6 +8263,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
crypto-js@4.2.0: {}
css-functions-list@3.3.3: {} css-functions-list@3.3.3: {}
css-tree@3.2.1: css-tree@3.2.1:
@ -7888,6 +8352,8 @@ snapshots:
dedent@1.7.2: {} dedent@1.7.2: {}
deep-eql@5.0.2: {}
deep-extend@0.6.0: {} deep-extend@0.6.0: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
@ -8079,6 +8545,8 @@ snapshots:
iterator.prototype: 1.1.5 iterator.prototype: 1.1.5
math-intrinsics: 1.1.0 math-intrinsics: 1.1.0
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.2: es-object-atoms@1.1.2:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@ -8368,10 +8836,16 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@5.0.4: {} eventemitter3@5.0.4: {}
expect-type@1.4.0: {}
ext-list@2.2.2: ext-list@2.2.2:
dependencies: dependencies:
mime-db: 1.54.0 mime-db: 1.54.0
@ -9216,6 +9690,8 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
loupe@3.2.1: {}
lower-case@1.1.4: {} lower-case@1.1.4: {}
lower-case@2.0.2: lower-case@2.0.2:
@ -9526,6 +10002,10 @@ snapshots:
path-type@4.0.0: {} path-type@4.0.0: {}
pathe@1.1.2: {}
pathval@2.0.1: {}
pend@1.2.0: {} pend@1.2.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
@ -9849,6 +10329,38 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
rollup@4.62.4:
dependencies:
'@types/estree': 1.0.9
optionalDependencies:
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
'@rollup/rollup-android-arm-eabi': 4.62.4
'@rollup/rollup-android-arm64': 4.62.4
'@rollup/rollup-darwin-arm64': 4.62.4
'@rollup/rollup-darwin-x64': 4.62.4
'@rollup/rollup-freebsd-arm64': 4.62.4
'@rollup/rollup-freebsd-x64': 4.62.4
'@rollup/rollup-linux-arm-gnueabihf': 4.62.4
'@rollup/rollup-linux-arm-musleabihf': 4.62.4
'@rollup/rollup-linux-arm64-gnu': 4.62.4
'@rollup/rollup-linux-arm64-musl': 4.62.4
'@rollup/rollup-linux-loong64-gnu': 4.62.4
'@rollup/rollup-linux-loong64-musl': 4.62.4
'@rollup/rollup-linux-ppc64-gnu': 4.62.4
'@rollup/rollup-linux-ppc64-musl': 4.62.4
'@rollup/rollup-linux-riscv64-gnu': 4.62.4
'@rollup/rollup-linux-riscv64-musl': 4.62.4
'@rollup/rollup-linux-s390x-gnu': 4.62.4
'@rollup/rollup-linux-x64-gnu': 4.62.4
'@rollup/rollup-linux-x64-musl': 4.62.4
'@rollup/rollup-openbsd-x64': 4.62.4
'@rollup/rollup-openharmony-arm64': 4.62.4
'@rollup/rollup-win32-arm64-msvc': 4.62.4
'@rollup/rollup-win32-ia32-msvc': 4.62.4
'@rollup/rollup-win32-x64-gnu': 4.62.4
'@rollup/rollup-win32-x64-msvc': 4.62.4
fsevents: 2.3.3
run-async@2.4.1: {} run-async@2.4.1: {}
run-parallel@1.2.0: run-parallel@1.2.0:
@ -10005,6 +10517,8 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {} signal-exit@3.0.7: {}
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
@ -10065,6 +10579,10 @@ snapshots:
split2@4.2.0: {} split2@4.2.0: {}
stackback@0.0.2: {}
std-env@3.10.0: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@ -10290,8 +10808,18 @@ snapshots:
tiny-case@1.0.3: {} tiny-case@1.0.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyexec@1.2.4: {} tinyexec@1.2.4: {}
tinypool@1.1.1: {}
tinyrainbow@1.2.0: {}
tinyspy@3.0.2: {}
to-buffer@1.2.2: to-buffer@1.2.2:
dependencies: dependencies:
isarray: 2.0.5 isarray: 2.0.5
@ -10450,6 +10978,24 @@ snapshots:
validate-npm-package-name@5.0.1: {} validate-npm-package-name@5.0.1: {}
vite-node@2.1.9(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite-plugin-static-copy@0.17.1(vite@4.5.14(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)): vite-plugin-static-copy@0.17.1(vite@4.5.14(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)):
dependencies: dependencies:
chokidar: 3.6.0 chokidar: 3.6.0
@ -10469,6 +11015,52 @@ snapshots:
sass: 1.100.0 sass: 1.100.0
terser: 5.48.0 terser: 5.48.0
vite@5.4.21(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.62.4
optionalDependencies:
'@types/node': 25.9.2
fsevents: 2.3.3
sass: 1.100.0
terser: 5.48.0
vitest@2.1.9(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0))
'@vitest/pretty-format': 2.1.9
'@vitest/runner': 2.1.9
'@vitest/snapshot': 2.1.9
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3
expect-type: 1.4.0
magic-string: 0.30.21
pathe: 1.1.2
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinypool: 1.1.1
tinyrainbow: 1.2.0
vite: 5.4.21(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)
vite-node: 2.1.9(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.9.2
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
wcwidth@1.0.1: wcwidth@1.0.1:
dependencies: dependencies:
defaults: 1.0.4 defaults: 1.0.4
@ -10532,6 +11124,11 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wildcard@2.0.1: {} wildcard@2.0.1: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}

17
src/api/pay.ts Normal file
View File

@ -0,0 +1,17 @@
import { request } from '@/lib/request'
export function getOrdersByPhone (phone: string) {
return request({
url: '/api/h5/order/phone',
method: 'GET',
params: { phone },
})
}
export function updateOrderExtra (data: Record<string, any>) {
return request({
url: '/api/h5/order',
method: 'PUT',
data,
})
}

76
src/api/user.ts Normal file
View File

@ -0,0 +1,76 @@
import Taro from '@tarojs/taro'
import { request } from '@/lib/request'
import { applyCorpInit } from '@/lib/session'
import { buildFileUrl, getFileExtension, pickPresignResult, QINIU_UPLOAD_URL } from '@/lib/qiniu'
export function getUserConfig (params: Record<string, any> = {}) {
return request({
url: '/api/user/config',
method: 'GET',
params,
})
}
export function initCorp (params: Record<string, any> = {}) {
return request({
url: '/api/h5/corp',
method: 'GET',
params,
}).then((res) => {
applyCorpInit(res)
return res
})
}
export function presign (ext = '') {
return request({
url: '/api/presign',
method: 'GET',
params: {
ext,
scene: 'process',
},
})
}
export async function uploadImage (filePath: string, onProgress?: (percent: number) => void) {
const ext = getFileExtension(filePath)
onProgress?.(0)
const presignResponse = await presign(ext)
const { id, token, key, domain } = pickPresignResult(presignResponse)
if (!id || !token || !key || !domain) {
throw new Error('获取上传凭证失败')
}
const uploaded = await new Promise<{ key?: string }>((resolve, reject) => {
const task = Taro.uploadFile({
url: QINIU_UPLOAD_URL,
filePath,
name: 'file',
formData: {
token,
key,
},
success (res) {
try {
const body = typeof res.data === 'string' ? JSON.parse(res.data || '{}') : res.data
resolve(body || {})
} catch {
resolve({})
}
},
fail (error) {
reject(error)
},
})
task.onProgressUpdate?.((event) => {
onProgress?.(Math.min(100, Math.max(0, event.progress)))
})
})
onProgress?.(100)
return {
id,
url: buildFileUrl(domain, uploaded.key || key),
}
}

View File

@ -1,13 +1,21 @@
export default defineAppConfig({ export default defineAppConfig({
pages: [ pages: [
'pages/index/index', 'pages/index/index',
'pages/landing/index', 'pages/map/index',
'pages/pay/index',
], ],
window: { window: {
backgroundTextStyle: 'light', backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff', navigationBarBackgroundColor: '#2F4FEE',
navigationBarTitleText: 'WeChat', navigationBarTitleText: '网上快办平台',
navigationBarTextStyle: 'black' navigationBarTextStyle: 'white'
} },
permission: {
'scope.userLocation': {
desc: '用于选择门店地图位置'
}
},
requiredPrivateInfos: [
'getLocation',
'chooseLocation',
],
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

47
src/lib/auth.test.ts Normal file
View File

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { ensureSessionToken, pickToken } from './auth'
describe('pickToken', () => {
it('reads token from the common config payload', () => {
expect(pickToken({ data: { token: 'tmp-token' } })).toBe('tmp-token')
})
it('reads a nested data.token', () => {
expect(pickToken({ data: { data: { token: 'nested-token' } } })).toBe('nested-token')
})
it('returns empty when missing', () => {
expect(pickToken({ data: { config: {} } })).toBe('')
})
})
describe('ensureSessionToken', () => {
it('keeps an existing jump token', async () => {
const token = await ensureSessionToken({
token: 'h5-token',
fetchUserConfig: async () => {
throw new Error('should not request config when token exists')
},
})
expect(token).toBe('h5-token')
})
it('loads a temporary token from /api/user/config when missing', async () => {
const token = await ensureSessionToken({
token: '',
fetchUserConfig: async () => ({ data: { token: 'guest-token' } }),
})
expect(token).toBe('guest-token')
})
it('fails when config does not return a token', async () => {
await expect(
ensureSessionToken({
token: '',
fetchUserConfig: async () => ({ data: {} }),
})
).rejects.toThrow('获取临时登录态失败')
})
})

25
src/lib/auth.ts Normal file
View File

@ -0,0 +1,25 @@
export function pickToken (payload: any): string {
const first = payload?.data ?? payload
const nested = first?.data && typeof first.data === 'object' && ('token' in first.data || !first.token)
? first.data
: first
const token = nested?.token ?? first?.token ?? payload?.token
return typeof token === 'string' ? token.trim() : ''
}
export async function ensureSessionToken (input: {
token?: string
fetchUserConfig: () => Promise<unknown>
}) {
const existing = String(input.token || '').trim()
if (existing) {
return existing
}
const token = pickToken(await input.fetchUserConfig())
if (!token) {
throw new Error('获取临时登录态失败')
}
return token
}

23
src/lib/bootstrap.ts Normal file
View File

@ -0,0 +1,23 @@
import { getUserConfig, initCorp } from '@/api/user'
import { ensureSessionToken } from './auth'
import { applyLaunchParams, applyServiceConfig, getSession, setSession } from './session'
export async function bootstrapSession (raw: Record<string, string | undefined> = {}) {
applyLaunchParams(raw)
try {
applyServiceConfig(await getUserConfig())
} catch {
// 配置拉取失败时仍可继续查询
}
const token = await ensureSessionToken({
token: getSession().token,
fetchUserConfig: () => getUserConfig(),
})
setSession({ token })
try {
await initCorp()
} catch {
// 已有临时 token 时,初始化失败不阻断查询
}
return getSession()
}

14
src/lib/cdn.ts Normal file
View File

@ -0,0 +1,14 @@
export const STATIC_CDN = 'https://cdn.u8t.cn/frontend-static/corp-h5/static'
export const CORP_ASSET_BASE = `${STATIC_CDN}/package-v3`
export const MAP_ASSET_BASE = `${STATIC_CDN}/package-v4`
export const HOME_ICON_BASE = `${MAP_ASSET_BASE}/images-pc/home`
export const MOBILE_ASSET_BASE = `${MAP_ASSET_BASE}/images-mobile`
export const BANNER_BG = `${MOBILE_ASSET_BASE}/bg-banner.png`
export const CARD_HEADER = `${MOBILE_ASSET_BASE}/card_header.png`
export const PAGE_TITLE = '网上快办平台'
export const ENTITY_LABELS = {
entityName: '门店名称',
entityAddress: '门店地址',
entityMapLocation: '门店定位',
}

34
src/lib/contact.test.ts Normal file
View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { hasServiceContact, pickServiceContact } from './contact'
describe('pickServiceContact', () => {
it('reads mobile online phone and weixin from client config', () => {
expect(pickServiceContact({
'client.service.online.mobile': 'https://chat.example.com',
'client.service.phone': '4001166311',
'client.service.weixin': 'https://work.weixin.qq.com/kfid/abc',
})).toEqual({
online: 'https://chat.example.com',
phone: '4001166311',
weixin: 'https://work.weixin.qq.com/kfid/abc',
})
})
it('falls back to flattened service fields', () => {
expect(pickServiceContact({
service_phone: '4000000000',
service_weixin: 'https://u.wechat.com/xyz',
})).toEqual({
online: '',
phone: '4000000000',
weixin: 'https://u.wechat.com/xyz',
})
})
})
describe('hasServiceContact', () => {
it('is true when any contact channel exists', () => {
expect(hasServiceContact({ 'client.service.phone': '4001166311' })).toBe(true)
expect(hasServiceContact({})).toBe(false)
})
})

35
src/lib/contact.ts Normal file
View File

@ -0,0 +1,35 @@
export type ServiceContact = {
online: string
phone: string
weixin: string
}
function asText (value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
export function pickServiceContact (config?: Record<string, any> | null): ServiceContact {
return {
online: asText(config?.['client.service.online.mobile'] || config?.service_online),
phone: asText(config?.['client.service.phone'] || config?.service_phone),
weixin: asText(config?.['client.service.weixin'] || config?.service_weixin),
}
}
export function hasServiceContact (config?: Record<string, any> | null) {
const contact = pickServiceContact(config)
return Boolean(contact.online || contact.phone || contact.weixin)
}
export function mergeServiceConfig (current: Record<string, any> | undefined, payload: any) {
const data = payload?.data ?? payload
const incoming = data?.config && typeof data.config === 'object' ? data.config : {}
const merged = {
...(current || {}),
...incoming,
}
if (merged['client.service.phone']) merged.service_phone = merged['client.service.phone']
if (merged['client.service.weixin']) merged.service_weixin = merged['client.service.weixin']
if (merged['client.service.online.mobile']) merged.service_online = merged['client.service.online.mobile']
return merged
}

21
src/lib/crypto.test.ts Normal file
View File

@ -0,0 +1,21 @@
import CryptoJS from 'crypto-js'
import Utf8 from 'crypto-js/enc-utf8'
import { describe, expect, it } from 'vitest'
import { decryptResponse } from './crypto'
describe('decryptResponse', () => {
it('decrypts the AES payload used by H5', () => {
const key = Utf8.parse('JphN7MRfPSsaydwMeYk6YhBquRz5Ck5K')
const iv = key.clone()
iv.sigBytes = 16
iv.clamp()
const payload = { code: 0, data: { token: 'tmp-token' } }
const encrypted = CryptoJS.AES.encrypt(JSON.stringify(payload), key, {
iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
}).toString()
expect(decryptResponse(encrypted)).toEqual(payload)
})
})

18
src/lib/crypto.ts Normal file
View File

@ -0,0 +1,18 @@
import CryptoJS from 'crypto-js'
import Utf8 from 'crypto-js/enc-utf8'
const AES_KEY = 'JphN7MRfPSsaydwMeYk6YhBquRz5Ck5K'
export function decryptResponse (encrypted: string) {
const key = Utf8.parse(AES_KEY)
const iv = key.clone()
iv.sigBytes = 16
iv.clamp()
const decrypted = CryptoJS.AES.decrypt(encrypted, key, {
iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
}).toString(Utf8)
return JSON.parse(decrypted)
}

73
src/lib/geocode.test.ts Normal file
View File

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import {
gcj02ToWgs84,
isRegionChangeEnd,
isUserRegionChange,
parseGeocoderResult,
pickMapCenter,
} from './geocode'
describe('pickMapCenter', () => {
it('reads nested WeChat regionchange centerLocation', () => {
expect(pickMapCenter({
detail: {
type: 'end',
causedBy: 'drag',
detail: {
centerLocation: { longitude: 121.4737, latitude: 31.2304 },
},
},
})).toEqual({ lng: 121.4737, lat: 31.2304 })
})
it('reads a flat centerLocation', () => {
expect(pickMapCenter({
detail: { centerLocation: { longitude: 116.4, latitude: 39.9 } },
})).toEqual({ lng: 116.4, lat: 39.9 })
})
})
describe('region change helpers', () => {
it('detects a finished user drag', () => {
const event = { detail: { type: 'end', causedBy: 'drag' } }
expect(isRegionChangeEnd(event)).toBe(true)
expect(isUserRegionChange(event)).toBe(true)
})
it('ignores programmatic updates and begin events', () => {
expect(isRegionChangeEnd({ detail: { type: 'begin', causedBy: 'gesture' } })).toBe(false)
expect(isUserRegionChange({ detail: { type: 'end', causedBy: 'update' } })).toBe(false)
expect(isUserRegionChange({ detail: { type: 'end' } })).toBe(true)
})
})
describe('parseGeocoderResult', () => {
it('prefers formatted address and poi name', () => {
expect(parseGeocoderResult({
result: {
formatted_address: '上海市黄浦区南京东路1号',
addressComponent: { poi: '外滩' },
},
})).toEqual({
address: '上海市黄浦区南京东路1号',
name: '外滩',
})
})
it('falls back to the formatted address when poi is missing', () => {
expect(parseGeocoderResult({
result: { formatted_address: '北京市东城区' },
})).toEqual({
address: '北京市东城区',
name: '北京市东城区',
})
})
})
describe('gcj02ToWgs84', () => {
it('matches the H5 GCJ-02 to Tianditu conversion for Shanghai', () => {
const [lng, lat] = gcj02ToWgs84(121.4737, 31.2304)
expect(lng).toBeCloseTo(121.469162, 6)
expect(lat).toBeCloseTo(31.232329, 6)
})
})

88
src/lib/geocode.ts Normal file
View File

@ -0,0 +1,88 @@
const GCJ_PI = Math.PI
const GCJ_A = 6378245
const GCJ_EE = 0.006693421622965943
export const TIANDITU_GEOCODER_URL = 'https://api.tianditu.gov.cn/geocoder'
export type GeoPoint = { lng: number, lat: number }
export type GeoPlace = { address: string, name: string }
function isOutsideChina (lng: number, lat: number) {
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
}
function transformLatitude (lng: number, lat: number) {
let result = -100 + 2 * lng + 3 * lat + 0.2 * lat * lat + 0.1 * lng * lat
result += 0.2 * Math.sqrt(Math.abs(lng))
result += (20 * Math.sin(6 * lng * GCJ_PI) + 20 * Math.sin(2 * lng * GCJ_PI)) * 2 / 3
result += (20 * Math.sin(lat * GCJ_PI) + 40 * Math.sin(lat / 3 * GCJ_PI)) * 2 / 3
result += (160 * Math.sin(lat / 12 * GCJ_PI) + 320 * Math.sin(lat * GCJ_PI / 30)) * 2 / 3
return result
}
function transformLongitude (lng: number, lat: number) {
let result = 300 + lng + 2 * lat + 0.1 * lng * lng + 0.1 * lng * lat
result += 0.1 * Math.sqrt(Math.abs(lng))
result += (20 * Math.sin(6 * lng * GCJ_PI) + 20 * Math.sin(2 * lng * GCJ_PI)) * 2 / 3
result += (20 * Math.sin(lng * GCJ_PI) + 40 * Math.sin(lng / 3 * GCJ_PI)) * 2 / 3
result += (150 * Math.sin(lng / 12 * GCJ_PI) + 300 * Math.sin(lng / 30 * GCJ_PI)) * 2 / 3
return result
}
function getGcjOffset (lng: number, lat: number): GeoPoint {
const latitudeRadians = lat / 180 * GCJ_PI
const magicBase = 1 - GCJ_EE * Math.sin(latitudeRadians) ** 2
const magicRoot = Math.sqrt(magicBase)
const latitudeOffset =
transformLatitude(lng - 105, lat - 35) * 180 /
((GCJ_A * (1 - GCJ_EE) / (magicBase * magicRoot)) * GCJ_PI)
const longitudeOffset =
transformLongitude(lng - 105, lat - 35) * 180 /
((GCJ_A / magicRoot * Math.cos(latitudeRadians)) * GCJ_PI)
return { lng: longitudeOffset, lat: latitudeOffset }
}
export function gcj02ToWgs84 (lng: number, lat: number): [number, number] {
if (isOutsideChina(lng, lat)) return [lng, lat]
let estimateLng = lng
let estimateLat = lat
for (let index = 0; index < 3; index += 1) {
const offset = getGcjOffset(estimateLng, estimateLat)
estimateLng = estimateLng + lng - (estimateLng + offset.lng)
estimateLat = estimateLat + lat - (estimateLat + offset.lat)
}
return [estimateLng, estimateLat]
}
function asRecord (value: unknown): Record<string, any> | null {
return value && typeof value === 'object' ? value as Record<string, any> : null
}
export function pickMapCenter (event: any): GeoPoint | null {
const detail = asRecord(event?.detail) || asRecord(event)
const inner = asRecord(detail?.detail) || detail
const center = asRecord(inner?.centerLocation) || asRecord(detail?.centerLocation)
const lng = Number(center?.longitude)
const lat = Number(center?.latitude)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
return { lng, lat }
}
export function isRegionChangeEnd (event: any) {
const detail = asRecord(event?.detail) || asRecord(event)
const type = detail?.type || asRecord(detail?.detail)?.type || event?.type
return type === 'end'
}
export function isUserRegionChange (event: any) {
const detail = asRecord(event?.detail) || asRecord(event)
const causedBy = detail?.causedBy || asRecord(detail?.detail)?.causedBy
return causedBy !== 'update'
}
export function parseGeocoderResult (payload: any): GeoPlace {
const result = asRecord(payload?.result) || asRecord(payload) || {}
const address = typeof result.formatted_address === 'string' ? result.formatted_address : ''
const poi = asRecord(result.addressComponent)?.poi
const name = typeof poi === 'string' && poi ? poi : address
return { address, name }
}

103
src/lib/launch.test.ts Normal file
View File

@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_LAUNCH_SCHEME,
parseLaunchQuery,
parseMiniProgramScheme,
resolveLaunchQuery,
} from './launch'
describe('parseMiniProgramScheme', () => {
it('reads the default weixin business scheme query', () => {
expect(parseMiniProgramScheme(DEFAULT_LAUNCH_SCHEME)).toEqual({
appId: 'wx6d4f6f29c41aff93',
path: 'pages/index/index',
scene: '/h/KZ9Q',
token: '6224a1fe-d88c-443c-8972-e6a0942d17fd',
host: 'nb.batiao8.com',
package: '10044',
phone: '13800138000',
})
})
})
describe('parseLaunchQuery', () => {
it('keeps token host and package from the H5 jump', () => {
expect(
parseLaunchQuery({
token: 'h5-token',
host: 'h5test.batiao8.com',
package: '10044',
scene: '%2Fh%2FKZ9Q',
})
).toEqual({
token: 'h5-token',
host: 'h5test.batiao8.com',
packageId: '10044',
scene: '/h/KZ9Q',
linkId: 'KZ9Q',
shareId: '',
prevId: '',
phone: '',
})
})
it('reads share and preview short links', () => {
expect(parseLaunchQuery({ scene: '/s/SHARE1' }).shareId).toBe('SHARE1')
expect(parseLaunchQuery({ scene: '/p/PREV9' }).prevId).toBe('PREV9')
})
it('ignores empty values', () => {
expect(parseLaunchQuery({})).toEqual({
token: '',
host: '',
packageId: '',
scene: '',
linkId: '',
shareId: '',
prevId: '',
phone: '',
})
expect(parseLaunchQuery({ phone: '13900001111' }).phone).toBe('13900001111')
})
})
describe('resolveLaunchQuery', () => {
it('fills missing fields from the default scheme', () => {
expect(resolveLaunchQuery({})).toEqual({
token: '6224a1fe-d88c-443c-8972-e6a0942d17fd',
host: 'nb.batiao8.com',
packageId: '10044',
scene: '/h/KZ9Q',
linkId: 'KZ9Q',
shareId: '',
prevId: '',
phone: '13800138000',
})
})
it('uses the launched phone when present', () => {
expect(resolveLaunchQuery({ phone: '13900001111' }).phone).toBe('13900001111')
})
it('keeps explicit jump params over defaults', () => {
expect(
resolveLaunchQuery({
token: 'h5-token',
host: 'h5test.batiao8.com',
package: '20001',
scene: '/s/SHARE1',
})
).toEqual({
token: 'h5-token',
host: 'h5test.batiao8.com',
packageId: '20001',
scene: '/s/SHARE1',
linkId: '',
shareId: 'SHARE1',
prevId: '',
phone: '13800138000',
})
})
})

83
src/lib/launch.ts Normal file
View File

@ -0,0 +1,83 @@
export type LaunchQuery = {
token: string
host: string
packageId: string
scene: string
linkId: string
shareId: string
prevId: string
phone: string
}
export const DEFAULT_LAUNCH_SCHEME =
'weixin://dl/business/?appid=wx6d4f6f29c41aff93&path=pages/index/index&query=scene%3D%2Fh%2FKZ9Q%26token%3D6224a1fe-d88c-443c-8972-e6a0942d17fd%26host%3Dnb.batiao8.com%26package%3D10044%26phone%3D13800138000'
export function parseMiniProgramScheme (scheme: string) {
const search = scheme.split('?')[1] || ''
const params = new URLSearchParams(search)
const inner = new URLSearchParams(params.get('query') || '')
return {
appId: params.get('appid') || '',
path: params.get('path') || '',
scene: inner.get('scene') || '',
token: inner.get('token') || '',
host: inner.get('host') || '',
package: inner.get('package') || '',
phone: inner.get('phone') || '',
}
}
function asText (value?: string) {
return String(value ?? '').trim()
}
function safeDecode (value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function parseSceneIds (scene: string) {
const decoded = safeDecode(scene).trim()
const matched = decoded.match(/\/(h|s|p)\/([^/?#]+)/)
if (!matched) {
return { linkId: '', shareId: '', prevId: '' }
}
const id = matched[2]
if (matched[1] === 'h') return { linkId: id, shareId: '', prevId: '' }
if (matched[1] === 's') return { linkId: '', shareId: id, prevId: '' }
return { linkId: '', shareId: '', prevId: id }
}
export function parseLaunchQuery (raw: Record<string, string | undefined> = {}): LaunchQuery {
const scene = safeDecode(asText(raw.scene))
return {
token: asText(raw.token),
host: asText(raw.host),
packageId: asText(raw.package),
scene,
phone: asText(raw.phone),
...parseSceneIds(scene),
}
}
export function resolveLaunchQuery (raw: Record<string, string | undefined> = {}): LaunchQuery {
const parsed = parseLaunchQuery(raw)
const defaults = parseLaunchQuery(parseMiniProgramScheme(DEFAULT_LAUNCH_SCHEME))
const scene = parsed.scene || defaults.scene
const sceneIds = parsed.scene
? { linkId: parsed.linkId, shareId: parsed.shareId, prevId: parsed.prevId }
: { linkId: defaults.linkId, shareId: defaults.shareId, prevId: defaults.prevId }
return {
token: parsed.token || defaults.token,
host: parsed.host || defaults.host,
packageId: parsed.packageId || defaults.packageId,
scene,
phone: parsed.phone || defaults.phone,
...sceneIds,
}
}

95
src/lib/order.test.ts Normal file
View File

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
buildResubmitPayload,
filterMapLabelOrders,
formatMapCoordinate,
getOrderAddressName,
getOrderExtraImages,
getOrderMapAddress,
joinUploadedImageIds,
parseCoordinate,
} from './order'
const mapOrder = {
order_id: '4',
goods_type: 'map_label',
entity_name: '老店',
entity_phone: '13800138000',
entity_extra: { keep: 1 },
extra: {
entity_address_name: '南京路1号',
entity_address: '121.473700,31.230400',
entity_storefront_image: '11,12',
entity_storefront_image_url: 'https://cdn.example/a.jpg,https://cdn.example/b.jpg',
origin_price: '99',
goods_param: '{"client.goods.icon":["https://cdn.example/i.png"]}',
},
}
describe('filterMapLabelOrders', () => {
it('keeps only map_label orders and parses goods_param', () => {
const orders = filterMapLabelOrders([
mapOrder,
{ ...mapOrder, order_id: '5', goods_type: 'license_year' },
] as any)
expect(orders).toHaveLength(1)
expect(orders[0].extra.goods_param).toEqual({
'client.goods.icon': ['https://cdn.example/i.png'],
})
})
})
describe('order address helpers', () => {
it('splits named address and coordinate address', () => {
expect(getOrderAddressName(mapOrder as any)).toBe('南京路1号')
expect(getOrderMapAddress(mapOrder as any)).toBe('121.473700,31.230400')
expect(parseCoordinate('121.4737, 31.2304')).toEqual({ lng: 121.4737, lat: 31.2304 })
expect(formatMapCoordinate({ lng: 121.4737, lat: 31.2304 })).toBe('121.473700,31.230400')
})
})
describe('order images', () => {
it('reads extra image urls and joins uploaded ids', () => {
expect(getOrderExtraImages(mapOrder as any, ['entity_storefront_image'])).toEqual([
{ id: '11', url: 'https://cdn.example/a.jpg', preview: 'https://cdn.example/a.jpg' },
{ id: '12', url: 'https://cdn.example/b.jpg', preview: 'https://cdn.example/b.jpg' },
])
expect(joinUploadedImageIds([
{ id: '11', url: 'https://cdn.example/a.jpg', preview: 'https://cdn.example/a.jpg' },
{ id: ' ', url: 'x', preview: 'x' },
])).toBe('11')
})
})
describe('buildResubmitPayload', () => {
it('updates extra while dropping upload-only keys', () => {
expect(
buildResubmitPayload({
order: mapOrder as any,
values: {
entity_name: '新店',
entity_address_name: '淮海路2号',
entity_address: '121.480000,31.240000',
entity_phone: '13900139000',
entity_phone2: '13700137000',
},
storefrontImages: [{ id: '21', url: 'https://cdn.example/c.jpg', preview: 'p' }],
licenseImages: [{ id: '31', url: 'https://cdn.example/d.jpg', preview: 'p' }],
})
).toEqual({
id: '4',
corp_id: 0,
entity_name: '新店',
entity_phone: '13900139000',
entity_extra: { keep: 1 },
extra: {
entity_address_name: '淮海路2号',
entity_address: '121.480000,31.240000',
entity_phone2: '13700137000',
entity_storefront_image: '21',
entity_business_license_image: '31',
},
})
})
})

160
src/lib/order.ts Normal file
View File

@ -0,0 +1,160 @@
export type UploadedImage = {
id: string
url: string
preview: string
}
export type OrderLike = {
order_id?: string
goods_type?: string
entity_name?: string
entity_phone?: string
entity_extra?: Record<string, unknown>
extra?: Record<string, any>
[key: string]: any
}
export type ResubmitValues = {
entity_name?: string
entity_address_name?: string
entity_address?: string
entity_phone?: string
entity_phone2?: string
}
const OMIT_EXTRA_KEYS = new Set([
'origin_price',
'qrCodeImgUrl',
'remoteIp',
'weixinAppId',
'entity_storefront_image',
'entity_business_license_image',
'entity_storefront_image_url',
'entity_business_license_image_url',
'goods_param',
])
function splitValues (value: unknown) {
if (value === undefined || value === null) return []
return String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
export function isCoordinateAddress (address?: string) {
if (!address) return false
const parts = address.split(',').map((part) => part.trim())
if (parts.length !== 2) return false
return parts.every((part) => Number.isFinite(Number(part)))
}
export function parseCoordinate (address?: string) {
if (!isCoordinateAddress(address)) return null
const [lng, lat] = String(address).split(',').map((part) => Number(part.trim()))
return { lng, lat }
}
export function formatMapCoordinate (point: { lng: number, lat: number }) {
return `${point.lng.toFixed(6)},${point.lat.toFixed(6)}`
}
export function getOrderAddressName (order: OrderLike) {
const extra = order.extra || {}
return extra.entity_address_name || (!isCoordinateAddress(extra.entity_address) ? extra.entity_address : '') || ''
}
export function getOrderMapAddress (order: OrderLike) {
const extra = order.extra || {}
return isCoordinateAddress(extra.entity_address) ? extra.entity_address : ''
}
export function getInitialPhone2 (order: OrderLike) {
const extra = order.extra || {}
return String(extra.entity_phone2 || extra.entity_phone_2 || extra.phone2 || '')
}
export function getGoodsIcons (order: OrderLike) {
const icons = order.extra?.goods_param?.['client.goods.icon']
return Array.isArray(icons) ? icons.filter((item) => typeof item === 'string') : []
}
export function filterMapLabelOrders<T extends OrderLike> (orders: T[] = []) {
return orders
.filter((item) => item.goods_type === 'map_label')
.map((item) => {
const nextItem = { ...item, extra: { ...(item.extra || {}) } }
const goodsParam = nextItem.extra.goods_param
if (typeof goodsParam === 'string') {
try {
nextItem.extra.goods_param = JSON.parse(goodsParam)
} catch {
nextItem.extra.goods_param = null
}
}
return nextItem
})
}
export function getOrderExtraImages (order: OrderLike | null | undefined, keys: string[]): UploadedImage[] {
const extra = order?.extra || {}
for (const key of keys) {
const values = splitValues(extra[key])
const explicitIds = splitValues(extra[`${key}_id`])
const explicitUrls = splitValues(extra[`${key}_url`])
const urls = explicitUrls.length > 0
? explicitUrls
: values.filter((value) => /^https?:\/\//i.test(value))
if (urls.length > 0) {
return urls.map((url, index) => {
const valueId = values[index] && values[index] !== url ? values[index] : ''
return {
id: valueId || explicitIds[index] || '',
url,
preview: url,
}
})
}
}
return []
}
export function joinUploadedImageIds (images: UploadedImage[]) {
return images
.map((image) => image.id.trim())
.filter(Boolean)
.join(',')
}
export function buildResubmitPayload (input: {
order: OrderLike
values: ResubmitValues
storefrontImages: UploadedImage[]
licenseImages: UploadedImage[]
}) {
const existingExtra = Object.entries(input.order.extra || {}).reduce<Record<string, unknown>>((result, [key, value]) => {
if (!OMIT_EXTRA_KEYS.has(key)) {
result[key] = value
}
return result
}, {})
return {
id: String(input.order.order_id || ''),
corp_id: 0,
entity_name: input.values.entity_name || input.order.entity_name || '',
entity_phone: input.values.entity_phone || '',
entity_extra: input.order.entity_extra || {},
extra: {
...existingExtra,
entity_address_name: input.values.entity_address_name || '',
entity_address: input.values.entity_address || '',
entity_phone2: input.values.entity_phone2 || '',
entity_storefront_image: joinUploadedImageIds(input.storefrontImages),
entity_business_license_image: joinUploadedImageIds(input.licenseImages),
},
}
}

56
src/lib/origin.test.ts Normal file
View File

@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from 'vitest'
import { hostFromOrigin, resolveApiOrigin, resolveRequestHost } from './origin'
describe('resolveApiOrigin', () => {
const original = process.env.TARO_APP_API_ORIGIN
afterEach(() => {
process.env.TARO_APP_API_ORIGIN = original
})
it('uses the configured local test origin', () => {
expect(resolveApiOrigin('http://corp-test.batiao8.com')).toBe('http://corp-test.batiao8.com')
})
it('uses the configured production origin', () => {
expect(resolveApiOrigin('https://nb.zuom8.cn')).toBe('https://nb.zuom8.cn')
})
it('strips a trailing slash', () => {
expect(resolveApiOrigin('https://nb.zuom8.cn/')).toBe('https://nb.zuom8.cn')
})
it('reads TARO_APP_API_ORIGIN when no argument is passed', () => {
process.env.TARO_APP_API_ORIGIN = 'http://corp-test.batiao8.com'
expect(resolveApiOrigin()).toBe('http://corp-test.batiao8.com')
})
it('throws when the env origin is missing', () => {
delete process.env.TARO_APP_API_ORIGIN
expect(() => resolveApiOrigin()).toThrow('缺少接口域名')
})
})
describe('hostFromOrigin', () => {
it('extracts the hostname from a local test origin', () => {
expect(hostFromOrigin('http://corp-test.batiao8.com')).toBe('corp-test.batiao8.com')
})
it('extracts the hostname from the official origin', () => {
expect(hostFromOrigin('https://nb.zuom8.cn')).toBe('nb.zuom8.cn')
})
})
describe('resolveRequestHost', () => {
it('prefers the launched host for x-host', () => {
expect(resolveRequestHost({
host: 'h5test.batiao8.com',
fallbackHost: 'nb.zuom8.cn',
})).toBe('h5test.batiao8.com')
})
it('falls back to the env host when no host is launched', () => {
expect(resolveRequestHost({ fallbackHost: 'nb.zuom8.cn' })).toBe('nb.zuom8.cn')
})
})

26
src/lib/origin.ts Normal file
View File

@ -0,0 +1,26 @@
export function normalizeHost (host?: string) {
return String(host || '')
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/+$/, '')
}
export function normalizeOrigin (origin?: string) {
return String(origin || '').trim().replace(/\/+$/, '')
}
export function hostFromOrigin (origin?: string) {
return normalizeHost(normalizeOrigin(origin).replace(/^https?:\/\//i, ''))
}
export function resolveApiOrigin (origin = process.env.TARO_APP_API_ORIGIN) {
const value = normalizeOrigin(origin)
if (!value) {
throw new Error('缺少接口域名')
}
return value
}
export function resolveRequestHost (input: { host?: string, fallbackHost?: string }) {
return normalizeHost(input.host) || normalizeHost(input.fallbackHost)
}

18
src/lib/phone.test.ts Normal file
View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { isValidPhone } from './phone'
describe('isValidPhone', () => {
it('accepts mainland mobile numbers', () => {
expect(isValidPhone('13800138000')).toBe(true)
})
it('accepts landline numbers', () => {
expect(isValidPhone('010-12345678')).toBe(true)
})
it('rejects empty or invalid values', () => {
expect(isValidPhone('')).toBe(false)
expect(isValidPhone('12345')).toBe(false)
expect(isValidPhone('23800138000')).toBe(false)
})
})

5
src/lib/phone.ts Normal file
View File

@ -0,0 +1,5 @@
const PHONE_PATTERN = /^(1[3-9]\d{9}|0\d{2,3}-?\d{7,8})$/
export function isValidPhone (phone: string) {
return PHONE_PATTERN.test(String(phone || '').trim())
}

31
src/lib/qiniu.test.ts Normal file
View File

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { buildFileUrl, pickPresignResult } from './qiniu'
describe('pickPresignResult', () => {
it('normalizes the presign payload used by H5', () => {
expect(
pickPresignResult({
code: 0,
data: {
id: 33013,
token: 'qiniu-upload-token',
key: 'upload/10034/a.jpg',
domain: 'https://cdn2.batiao8.com',
},
})
).toEqual({
id: '33013',
token: 'qiniu-upload-token',
key: 'upload/10034/a.jpg',
domain: 'https://cdn2.batiao8.com',
})
})
})
describe('buildFileUrl', () => {
it('joins domain and key without duplicate slashes', () => {
expect(buildFileUrl('https://cdn2.batiao8.com/', '/upload/a.jpg')).toBe(
'https://cdn2.batiao8.com/upload/a.jpg'
)
})
})

37
src/lib/qiniu.ts Normal file
View File

@ -0,0 +1,37 @@
export const QINIU_UPLOAD_URL = 'https://up-cn-east-2.qiniup.com'
export type PresignResult = {
id: string
token: string
key: string
domain: string
}
export function pickPresignResult (response: any): PresignResult {
const payload = response?.data ?? response
const data = payload?.data && typeof payload.data === 'object' ? payload.data : payload
return {
id: data?.id === undefined || data?.id === null ? '' : String(data.id),
token: typeof data?.token === 'string' ? data.token : '',
key: typeof data?.key === 'string' ? data.key : '',
domain: typeof data?.domain === 'string' ? data.domain : '',
}
}
export function buildFileUrl (domain: string, key: string) {
if (!domain) return key
const normalizedDomain = domain.endsWith('/') ? domain.slice(0, -1) : domain
const normalizedKey = key.startsWith('/') ? key.slice(1) : key
return `${normalizedDomain}/${normalizedKey}`
}
export function getFileExtension (filePath: string) {
const name = filePath.split('?')[0]
const slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'))
const fileName = slash >= 0 ? name.slice(slash + 1) : name
const dotIndex = fileName.lastIndexOf('.')
if (dotIndex >= 0 && dotIndex < fileName.length - 1) {
return fileName.slice(dotIndex + 1).toLowerCase()
}
return 'jpg'
}

144
src/lib/request.ts Normal file
View File

@ -0,0 +1,144 @@
import Taro from '@tarojs/taro'
import { decryptResponse } from './crypto'
import { resolveApiOrigin, resolveRequestHost } from './origin'
import { signRequest } from './sign'
import { getSession, getSessionHost, setSession } from './session'
export const REQUEST_TIMEOUT = 60000
export const APP_VERSION = '3.0.0'
export const CORP_ID = '10006'
type RequestOptions = {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
params?: Record<string, any>
data?: any
silent?: boolean
}
export type BusinessResponse<T = any> = {
code: number
data: T
message?: string
msg?: string
encrypt?: boolean
}
let waitGroup = 0
function showLoading () {
waitGroup += 1
if (waitGroup === 1) {
Taro.showLoading({ title: '加载中...', mask: true })
}
}
function hideLoading () {
waitGroup = Math.max(0, waitGroup - 1)
if (waitGroup === 0) {
Taro.hideLoading()
}
}
function getResponseMessage (data: any) {
const message = data?.message ?? data?.msg ?? '请求失败'
return String(message).split(',')[0] || '请求失败'
}
function parseResponseBody (data: any) {
if (typeof data !== 'string') return data
const text = data.trim()
if (!text) return data
try {
return JSON.parse(text)
} catch {
return data
}
}
function toQuery (params: Record<string, any>) {
return Object.keys(params)
.map((key) => `${key}=${encodeURIComponent(params[key] ?? '')}`)
.join('&')
}
function buildHeaders () {
const session = getSession()
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-device-id': session.deviceId,
'x-platform': 'wx-mp',
'x-channel': 'wx-mp',
'x-version': APP_VERSION,
'x-host': resolveRequestHost({
host: getSessionHost(),
fallbackHost: process.env.TARO_APP_API_HOST,
}),
'b-corp-id': CORP_ID,
}
if (session.linkId) headers['b-link-id'] = session.linkId
if (session.shareId) headers['b-share-id'] = session.shareId
if (session.prevId) headers['b-prev-id'] = session.prevId
if (session.token) headers['x-token'] = session.token
if (session.packageId) headers['x-package'] = session.packageId
return headers
}
export async function request<T = any> (options: RequestOptions): Promise<BusinessResponse<T>> {
const method = options.method || 'GET'
const timestamp = Math.floor(Date.now() / 1000)
const nonce = `${timestamp}-${Math.random().toString(16).slice(2)}`
const signed = signRequest({
method,
params: {
...(options.params || {}),
timestamp,
nonce,
},
data: options.data,
})
const origin = resolveApiOrigin()
const url = `${origin}${options.url}?${toQuery(signed.params)}`
if (!options.silent) showLoading()
try {
const response = await Taro.request({
url,
method,
data: method === 'GET' || method === 'DELETE' ? undefined : signed.data,
header: buildHeaders(),
timeout: REQUEST_TIMEOUT,
})
let payload = parseResponseBody(response.data) as BusinessResponse<T>
if (payload && typeof payload === 'object' && payload.encrypt) {
payload = decryptResponse((payload as any).data)
}
payload = parseResponseBody(payload)
if (!payload || payload.code !== 0) {
const message = getResponseMessage(payload)
if (!options.silent) {
Taro.showToast({ title: message, icon: 'none' })
}
if (payload?.code === 11022 || payload?.code === 1001003 || payload?.code === 1001004) {
setSession({ token: '' })
}
throw new Error(message)
}
return payload
} catch (error) {
if (error instanceof Error && error.message && error.message !== 'request:fail') {
throw error
}
const message = '当前无法连接服务,请稍后再试'
if (!options.silent) {
Taro.showToast({ title: message, icon: 'none' })
}
throw new Error(message)
} finally {
if (!options.silent) hideLoading()
}
}

20
src/lib/reverseGeocode.ts Normal file
View File

@ -0,0 +1,20 @@
import Taro from '@tarojs/taro'
import { gcj02ToWgs84, parseGeocoderResult, TIANDITU_GEOCODER_URL, type GeoPlace } from './geocode'
export async function reverseGeocode (lng: number, lat: number): Promise<GeoPlace> {
const key = process.env.TARO_APP_TIANDITU_KEY
if (!key) return { address: '', name: '' }
const [wgsLng, wgsLat] = gcj02ToWgs84(lng, lat)
const response = await Taro.request({
url: TIANDITU_GEOCODER_URL,
method: 'GET',
data: {
postStr: JSON.stringify({ lon: wgsLng, lat: wgsLat, ver: 1 }),
type: 'geocode',
tk: key,
},
})
return parseGeocoderResult(response.data)
}

105
src/lib/session.ts Normal file
View File

@ -0,0 +1,105 @@
import Taro from '@tarojs/taro'
import { mergeServiceConfig } from './contact'
import { resolveLaunchQuery, type LaunchQuery } from './launch'
import { hostFromOrigin, resolveApiOrigin } from './origin'
import { createDeviceId } from './uuid'
const SESSION_KEY = 'corpMpSession'
export type SessionState = LaunchQuery & {
deviceId: string
config: Record<string, any>
}
const EMPTY_SESSION: SessionState = {
token: '',
host: '',
packageId: '',
scene: '',
linkId: '',
shareId: '',
prevId: '',
phone: '',
deviceId: '',
config: {},
}
function readStorage (): Partial<SessionState> {
try {
const value = Taro.getStorageSync(SESSION_KEY)
return value && typeof value === 'object' ? value : {}
} catch {
return {}
}
}
function writeStorage (session: SessionState) {
try {
Taro.setStorageSync(SESSION_KEY, session)
} catch {
// ignore quota / private mode
}
}
let memorySession: SessionState | null = null
export function getSession (): SessionState {
if (!memorySession) {
const stored = readStorage()
memorySession = {
...EMPTY_SESSION,
...stored,
deviceId: stored.deviceId || createDeviceId(),
}
writeStorage(memorySession)
}
return memorySession
}
export function setSession (patch: Partial<SessionState>) {
memorySession = {
...getSession(),
...patch,
}
writeStorage(memorySession)
return memorySession
}
export function applyLaunchParams (raw: Record<string, string | undefined> = {}) {
const launch = resolveLaunchQuery(raw)
const current = getSession()
return setSession({
token: launch.token || current.token,
host: launch.host || current.host,
packageId: launch.packageId || current.packageId,
scene: launch.scene || current.scene,
linkId: launch.linkId || current.linkId,
shareId: launch.shareId || current.shareId,
prevId: launch.prevId || current.prevId,
phone: launch.phone || current.phone,
})
}
export function getSessionHost () {
const session = getSession()
if (session.host) return session.host
return process.env.TARO_APP_API_HOST || hostFromOrigin(resolveApiOrigin())
}
export function applyServiceConfig (payload: any) {
return setSession({
config: mergeServiceConfig(getSession().config, payload),
})
}
export function applyCorpInit (payload: any) {
const data = payload?.data ?? payload
const token = typeof data?.token === 'string' ? data.token.trim() : ''
const packageId = data?.package === undefined || data?.package === null ? '' : String(data.package)
const patch: Partial<SessionState> = {
config: mergeServiceConfig(getSession().config, payload),
}
if (token) patch.token = token
if (packageId) patch.packageId = packageId
return setSession(patch)
}

50
src/lib/sign.test.ts Normal file
View File

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { signRequest } from './sign'
describe('signRequest', () => {
it('signs GET query params the same way as H5', () => {
const signed = signRequest({
method: 'GET',
params: {
phone: '13800138000',
timestamp: 1700000000,
nonce: 'abc-nonce',
},
})
expect(signed.params.signature).toBe('170c9063021f3863d00c32b5ddffeb01')
})
it('signs POST with query params plus JSON body', () => {
const signed = signRequest({
method: 'POST',
params: {
phone: '13800138000',
timestamp: 1700000000,
nonce: 'abc-nonce',
},
data: {
id: '12',
entity_name: '店',
},
})
expect(signed.params.signature).toBe('0b7bcf9a71df45452fda85a3a45b9a8a')
})
it('treats PUT like POST', () => {
const signed = signRequest({
method: 'PUT',
params: {
timestamp: 1700000000,
nonce: 'abc-nonce',
},
data: {
id: '12',
},
})
expect(typeof signed.params.signature).toBe('string')
expect(signed.params.signature).toHaveLength(32)
})
})

31
src/lib/sign.ts Normal file
View File

@ -0,0 +1,31 @@
import MD5 from 'crypto-js/md5'
export const SIGN_KEY = 'MsvqoWCMxJsozicF5K4EFVSoVf8rEHfn'
type SignInput = {
method?: string
params?: Record<string, any>
data?: any
}
export function signRequest (input: SignInput) {
const method = (input.method || 'GET').toUpperCase()
const params = { ...(input.params || {}) }
const data = input.data && typeof input.data === 'object' ? input.data : {}
const keys = Object.keys(params).sort()
const query = keys
.map((key) => `${key}=${encodeURIComponent(params[key])}`)
.join('&')
const secret = MD5(SIGN_KEY).toString()
const source = method === 'POST' || method === 'PUT'
? `${query}&${JSON.stringify(data)}&${secret}`
: `${query}&${secret}`
return {
params: {
...params,
signature: MD5(source).toString(),
},
data,
}
}

23
src/lib/status.ts Normal file
View File

@ -0,0 +1,23 @@
export const STATUS_CONFIG: Record<string, { color: string, bg: string, result: string }> = {
'4': { color: '#FF8E13', bg: '#FFF2E4', result: '处理中' },
'1': { color: '#FF431D', bg: '#fff1e6', result: '待办理' },
'3': { color: '#FF431D', bg: '#fff1e6', result: '待办理' },
'2': { color: '#37D2AD', bg: '#DFF8F2', result: '已完成' },
'5': { color: '#37D2AD', bg: '#DFF8F2', result: '已完成' },
'7': { color: '#37D2AD', bg: '#DFF8F2', result: '已完成' },
'8': { color: '#37D2AD', bg: '#DFF8F2', result: '已完成' },
'6': { color: '#FF8E13', bg: '#FFF2E4', result: '待提交' },
}
export const PAY_ICON: Record<string, string> = {
alipay: 'https://cdn.u8t.cn/frontend-static/corp-h5/static/images/alipay.png',
weixin: 'https://cdn.u8t.cn/frontend-static/corp-h5/static/images/weixin.png',
}
export function getStatusMeta (status?: string, name?: string) {
const config = STATUS_CONFIG[String(status || '')] || { color: '#666666', bg: '#F5F5F5', result: '未知' }
return {
...config,
result: name || config.result,
}
}

7
src/lib/uuid.ts Normal file
View File

@ -0,0 +1,7 @@
export function createDeviceId () {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
const random = Math.floor(Math.random() * 16)
const value = char === 'x' ? random : (random & 0x3) | 0x8
return value.toString(16)
})
}

101
src/pages/index/contact.tsx Normal file
View File

@ -0,0 +1,101 @@
import { Image, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { CORP_ASSET_BASE, STATIC_CDN } from '@/lib/cdn'
import { pickServiceContact, type ServiceContact } from '@/lib/contact'
type Props = {
open: boolean
contact: ServiceContact
onCancel: () => void
}
function ContactItem ({
icon,
title,
desc,
onClick,
}: {
icon: string
title: string
desc: string
onClick: () => void
}) {
return (
<View className='contact-item' onClick={onClick}>
<Image className='contact-item__icon' src={icon} />
<View className='contact-item__copy'>
<Text className='contact-item__title'>{title}</Text>
<Text className='contact-item__desc'>{desc}</Text>
</View>
</View>
)
}
export default function ContactModal ({ open, contact, onCancel }: Props) {
if (!open) return null
const copyLink = async (url: string, title: string) => {
await Taro.setClipboardData({ data: url })
Taro.showToast({ title: `${title}链接已复制`, icon: 'none' })
}
const handlePhone = () => {
if (!contact.phone) return
Taro.makePhoneCall({ phoneNumber: contact.phone.replace(/-/g, '') })
}
const hasAny = Boolean(contact.online || contact.phone || contact.weixin)
return (
<View className='contact-mask' onClick={onCancel}>
<View className='contact-dialog' onClick={(event) => event.stopPropagation()}>
<Text className='contact-dialog__title'></Text>
<Text className='contact-dialog__hours'> 9:00-18:00</Text>
{contact.online ? (
<ContactItem
icon={`${CORP_ASSET_BASE}/images-pc/icon-contactus-service.png`}
title='在线客服'
desc='竭诚为您服务~'
onClick={() => void copyLink(contact.online, '在线客服')}
/>
) : null}
{contact.weixin ? (
<ContactItem
icon={`${CORP_ASSET_BASE}/images-pc/icon-service-weixin.png`}
title='微信客服'
desc='一对一为您服务~'
onClick={() => void copyLink(contact.weixin, '微信客服')}
/>
) : null}
{contact.phone ? (
<ContactItem
icon={`${CORP_ASSET_BASE}/images-pc/icon-contactus-phone.png`}
title='电话客服'
desc={contact.phone}
onClick={handlePhone}
/>
) : null}
{!hasAny ? (
<Text className='contact-dialog__empty'></Text>
) : null}
<View className='contact-dialog__close' onClick={onCancel}>
<Text></Text>
</View>
</View>
</View>
)
}
export function ContactEntry ({ onClick }: { onClick: () => void }) {
return (
<View className='contact-entry' onClick={onClick}>
<Image className='contact-entry__icon' src={`${STATIC_CDN}/images/icon-contract.png`} />
<Text className='contact-entry__text'></Text>
</View>
)
}

View File

@ -1,3 +1,5 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '首页' navigationBarTitleText: '网上快办平台',
navigationBarBackgroundColor: '#2F4FEE',
navigationBarTextStyle: 'white',
}) })

View File

@ -0,0 +1,616 @@
.check-page {
min-height: 100vh;
box-sizing: border-box;
padding-bottom: 48px;
background: #2f4fee;
}
.check-hero {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 420px;
}
.check-hero__bg {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
}
.check-hero__title {
position: relative;
z-index: 1;
color: #fff;
font-size: 72px;
font-weight: 700;
letter-spacing: 6px;
line-height: 96px;
text-align: center;
text-shadow: 0 4px 8px #1840ff;
}
.check-card {
position: relative;
z-index: 1;
margin: -80px 24px 24px;
}
.check-card__header {
position: relative;
height: 154px;
}
.check-card__header-bg {
display: block;
width: 100%;
height: 154px;
}
.check-card__header-title {
position: absolute;
right: 0;
bottom: 28px;
left: 16px;
color: #fff;
font-size: 50px;
font-weight: 700;
line-height: 60px;
text-align: center;
text-shadow: 0 8px 0 #0026b3;
}
.check-card__shell {
padding: 0 16px 16px;
border-radius: 0 0 28px 28px;
background: #cbdeff;
}
.check-card__body,
.check-card__list {
padding: 40px 24px 40px;
border-radius: 0 0 28px 28px;
background: #fff;
}
.check-card__note {
display: block;
margin-bottom: 24px;
color: #ff2626;
font-size: 24px;
line-height: 32px;
}
.check-card__label {
display: flex;
align-items: center;
margin-bottom: 16px;
color: #666;
font-size: 28px;
line-height: 40px;
}
.check-card__label-icon {
width: 40px;
height: 40px;
margin-right: 8px;
}
.check-card__required {
margin-left: 4px;
color: #ff2f2f;
}
.check-card__input {
box-sizing: border-box;
height: 88px;
padding: 0 24px;
border: 1px solid #ddd;
border-radius: 8px;
color: #222;
font-size: 28px;
}
.check-card__button {
margin-top: 32px;
height: 88px;
border: 0;
border-radius: 8px;
background: #0261fc;
color: #fff;
font-size: 30px;
font-weight: 700;
line-height: 88px;
}
.check-card__button::after {
border: 0;
}
.check-card__empty {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 28px;
}
.order-card {
padding: 24px 0 32px;
border-top: 1px solid #eee;
}
.order-card--first {
padding-top: 0;
border-top: 0;
}
.order-card__head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
}
.order-card__goods {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
}
.order-card__goods-icon {
width: 40px;
height: 40px;
margin-right: -8px;
border-radius: 20px;
}
.order-card__goods-name {
margin-left: 16px;
color: #222;
font-size: 28px;
font-weight: 500;
}
.order-card__status {
flex-shrink: 0;
min-width: 102px;
height: 44px;
padding: 0 12px;
border-radius: 8px;
font-size: 24px;
line-height: 44px;
text-align: center;
}
.order-card__reject {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #fff3f3;
color: #ff0707;
font-size: 24px;
line-height: 36px;
}
.order-card__row {
display: flex;
align-items: flex-start;
margin-top: 16px;
font-size: 28px;
line-height: 36px;
}
.order-card__label {
flex-shrink: 0;
color: #666;
}
.order-card__value {
flex: 1;
min-width: 0;
color: #222;
word-break: break-all;
}
.order-card__value--id {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.order-card__copy {
width: 32px;
height: 32px;
margin-left: 12px;
}
.order-card__pay-icon {
width: 40px;
height: 40px;
margin-right: 8px;
}
.order-card__fee {
color: #ff2f2f;
font-size: 30px;
font-weight: 700;
}
.order-card__actions {
display: flex;
justify-content: flex-end;
margin-top: 24px;
}
.order-card__action {
min-width: 160px;
height: 64px;
margin-left: 16px;
border-radius: 12px;
background: #eaf3ff;
color: #0261fc;
font-size: 28px;
line-height: 64px;
text-align: center;
}
.detail-mask,
.resubmit-mask {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.8);
}
.detail-dialog {
position: absolute;
top: 50%;
right: 32px;
left: 32px;
padding: 32px 24px 24px;
border-radius: 16px;
background: #fff;
transform: translateY(-50%);
}
.detail-dialog__title {
display: block;
margin-bottom: 24px;
color: #222;
font-size: 32px;
font-weight: 600;
text-align: center;
}
.detail-dialog__close {
margin-top: 32px;
height: 80px;
border: 0;
border-radius: 8px;
background: #0261fc;
color: #fff;
font-size: 30px;
line-height: 80px;
}
.detail-dialog__close::after {
border: 0;
}
.resubmit-mask {
display: flex;
align-items: flex-end;
}
.resubmit {
position: relative;
box-sizing: border-box;
width: 100%;
max-height: 84vh;
padding: 32px 24px 40px;
overflow-y: auto;
border-radius: 24px 24px 0 0;
background: linear-gradient(180deg, #c5eeff 0, #fff 136px) #fff;
}
.resubmit__close {
position: absolute;
top: 24px;
right: 24px;
width: 40px;
height: 40px;
}
.resubmit__close-text {
color: #b3b3b3;
font-size: 48px;
line-height: 40px;
}
.resubmit__title {
display: block;
margin-bottom: 24px;
color: #222;
font-size: 34px;
font-weight: 500;
text-align: center;
}
.resubmit__reject {
margin-bottom: 16px;
padding: 20px 24px;
border-radius: 12px;
background: #fff1f1;
color: #ff0707;
font-size: 24px;
line-height: 36px;
}
.resubmit__field {
margin-bottom: 24px;
}
.resubmit__label {
display: flex;
align-items: center;
margin-bottom: 12px;
color: #3d4044;
font-size: 28px;
}
.resubmit__label-icon {
width: 40px;
height: 40px;
margin-right: 8px;
}
.resubmit__required {
margin-left: 4px;
color: #ff2f2f;
}
.resubmit__input,
.resubmit__map-input {
box-sizing: border-box;
height: 88px;
padding: 0 18px;
border: 1px solid #ddd;
border-radius: 12px;
color: #222;
font-size: 28px;
}
.resubmit__map-input {
display: flex;
align-items: center;
justify-content: space-between;
}
.resubmit__map-value {
flex: 1;
color: #222;
}
.resubmit__map-placeholder {
flex: 1;
color: #b3b3b3;
}
.resubmit__map-arrow {
width: 40px;
height: 40px;
}
.resubmit__tip {
display: block;
margin-bottom: 24px;
color: #999;
font-size: 24px;
line-height: 36px;
}
.resubmit__upload-list {
display: flex;
flex-wrap: wrap;
}
.resubmit__upload-item,
.resubmit__upload-slot {
position: relative;
width: 200px;
height: 200px;
margin: 0 16px 16px 0;
overflow: hidden;
border-radius: 12px;
background: #f7f8fa;
}
.resubmit__upload-image {
width: 100%;
height: 100%;
}
.resubmit__upload-remove {
position: absolute;
top: 8px;
right: 8px;
width: 36px;
height: 36px;
border-radius: 18px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 28px;
line-height: 36px;
text-align: center;
}
.resubmit__upload-slot {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px dashed #cfd6de;
}
.resubmit__upload-plus {
width: 56px;
height: 56px;
}
.resubmit__upload-text {
margin-top: 12px;
color: #999;
font-size: 22px;
}
.resubmit__error {
display: block;
margin-bottom: 16px;
color: #ff2f2f;
font-size: 24px;
}
.resubmit__submit {
height: 88px;
border: 0;
border-radius: 12px;
background: #0261fc;
color: #fff;
font-size: 32px;
font-weight: 700;
line-height: 88px;
}
.resubmit__submit::after {
border: 0;
}
.contact-entry {
position: fixed;
top: 24px;
right: 0;
z-index: 15;
box-sizing: border-box;
display: flex;
align-items: center;
width: 204px;
height: 60px;
padding-left: 20px;
border-radius: 268px 0 0 268px;
background: rgba(0, 0, 0, 0.5);
}
.contact-entry__icon {
width: 40px;
height: 40px;
margin-right: 4px;
flex-shrink: 0;
}
.contact-entry__text {
color: #fff;
font-size: 30px;
line-height: 44px;
}
.contact-mask {
position: fixed;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
}
.contact-dialog {
box-sizing: border-box;
width: 576px;
padding: 32px 28px 28px;
border-radius: 32px;
background: #fff;
}
.contact-dialog__title {
display: block;
color: #222;
font-size: 34px;
font-weight: 600;
line-height: 48px;
text-align: center;
}
.contact-dialog__hours {
display: block;
margin: 16px 0 28px;
color: #999;
font-size: 24px;
line-height: 32px;
text-align: center;
}
.contact-dialog__empty {
display: block;
padding: 24px 0;
color: #999;
font-size: 26px;
text-align: center;
}
.contact-item {
display: flex;
align-items: center;
height: 124px;
margin-bottom: 20px;
padding: 0 24px;
border-radius: 16px;
background: #fff;
box-shadow: 0 0 20px rgba(147, 153, 161, 0.12);
}
.contact-item__icon {
width: 76px;
height: 76px;
margin-right: 24px;
flex-shrink: 0;
}
.contact-item__copy {
display: flex;
flex-direction: column;
}
.contact-item__title {
color: #222;
font-size: 28px;
font-weight: 500;
line-height: 40px;
}
.contact-item__desc {
margin-top: 4px;
color: #999;
font-size: 24px;
line-height: 32px;
}
.contact-dialog__close {
margin-top: 12px;
height: 80px;
border-radius: 16px;
background: #f5f6f8;
color: #666;
font-size: 30px;
line-height: 80px;
text-align: center;
}

View File

@ -1,97 +1,283 @@
import { View, WebView } from '@tarojs/components' import { Button, Image, Input, Text, View } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro' import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { useState } from 'react' import { useState } from 'react'
import { getOrdersByPhone } from '@/api/pay'
import { bootstrapSession } from '@/lib/bootstrap'
import { BANNER_BG, CARD_HEADER, CORP_ASSET_BASE, ENTITY_LABELS, HOME_ICON_BASE, PAGE_TITLE } from '@/lib/cdn'
import { pickServiceContact, type ServiceContact } from '@/lib/contact'
import {
filterMapLabelOrders,
getGoodsIcons,
getOrderAddressName,
parseCoordinate,
type OrderLike,
type ResubmitValues,
} from '@/lib/order'
import { isValidPhone } from '@/lib/phone'
import { getStatusMeta, PAY_ICON } from '@/lib/status'
import { consumeMapPickerResult, type MapPickerResult } from '@/pages/map/picker'
import ContactModal, { ContactEntry } from './contact'
import ResubmitDrawer from './resubmit'
import './index.scss' import './index.scss'
// xunmeng2,devcon
const LINK_PREFIX = '/p/' const COPY_ICON = `${CORP_ASSET_BASE}/images-mobile/icon-copy.png`
const LANDING_PAGE_URL = '/pages/landing/index'
const SCENE_STORAGE_KEY = 'qrcodeScene'
function isValidLinkCode (code: string) { export default function Index () {
return Boolean(code) && !/[/?#&=]/.test(code) const [ready, setReady] = useState(false)
} const [inputPhone, setInputPhone] = useState('')
const [checking, setChecking] = useState(false)
const [searched, setSearched] = useState(false)
const [orders, setOrders] = useState<OrderLike[]>([])
const [detailOrder, setDetailOrder] = useState<OrderLike | null>(null)
const [resubmitOrder, setResubmitOrder] = useState<OrderLike | null>(null)
const [mapResult, setMapResult] = useState<MapPickerResult | null>(null)
const [showContact, setShowContact] = useState(false)
const [contact, setContact] = useState<ServiceContact>({ online: '', phone: '', weixin: '' })
function normalizeQrLinkCode (scene?: string) { useLoad(async (options) => {
if (!scene) return '' try {
const session = await bootstrapSession(options || {})
try { setInputPhone(session.phone)
const decodedScene = decodeURIComponent(scene) setContact(pickServiceContact(session.config))
const linkParam = new URLSearchParams(decodedScene).get('link') setReady(true)
const rawCode = (linkParam || decodedScene).trim() } catch (error) {
const code = rawCode.startsWith(LINK_PREFIX) Taro.showToast({
? rawCode.slice(LINK_PREFIX.length) title: error instanceof Error ? error.message : '初始化失败',
: rawCode icon: 'none',
})
return isValidLinkCode(code) ? code : ''
} catch (error) {
console.warn('Invalid qrcode scene.', error)
return ''
}
}
function buildWebViewUrl (linkCode: string, mpCode: string, options: Record<string, string>) {
const queryParams = new URLSearchParams()
Object.entries(options).forEach(([key, value]) => {
if (typeof value === 'string') {
queryParams.set(key, value)
} }
}) })
queryParams.set('mpCode', mpCode) useDidShow(() => {
const result = consumeMapPickerResult()
return `https://nb.zuom8.cn${LINK_PREFIX}${encodeURIComponent(linkCode)}?${queryParams.toString()}` if (result) {
} setMapResult(result)
export default function Index () {
const [url, setUrl] = useState('')
useLoad(async (options) => {
const currentScene = typeof options?.scene === 'string' ? options.scene.trim() : ''
let scene = currentScene
if (currentScene) {
try {
Taro.setStorageSync(SCENE_STORAGE_KEY, currentScene)
} catch (error) {
console.warn('Failed to save qrcode scene.', error)
}
} else {
try {
const storedScene = Taro.getStorageSync(SCENE_STORAGE_KEY)
scene = typeof storedScene === 'string' ? storedScene.trim() : ''
} catch (error) {
console.warn('Failed to read stored qrcode scene.', error)
}
} }
})
const linkCode = normalizeQrLinkCode(scene) const queryOrders = async (phone: string) => {
if (!phone) {
if (!linkCode) { Taro.showToast({ title: '请输入联系电话', icon: 'none' })
try { return
await Taro.redirectTo({ url: LANDING_PAGE_URL }) }
} catch (error) { if (!isValidPhone(phone)) {
console.error('Failed to open landing page.', error) Taro.showToast({ title: '请填写正确联系电话', icon: 'none' })
Taro.showToast({
title: '页面加载失败',
icon: 'none'
})
}
return return
} }
const wxCode = await Taro.login() setChecking(true)
try {
const res = await getOrdersByPhone(phone)
const nextOrders = filterMapLabelOrders(res.data || [])
setOrders(nextOrders)
setSearched(true)
if (!nextOrders.length) {
Taro.showToast({ title: '无记录', icon: 'none' })
}
} catch {
Taro.showToast({ title: '当前无法查询,请稍后再试', icon: 'none' })
} finally {
setChecking(false)
}
}
setUrl(buildWebViewUrl(linkCode, wxCode.code, options)) const handleChooseMap = (draft: ResubmitValues) => {
}) const point = parseCoordinate(draft.entity_address)
const query = [
point ? `lng=${point.lng}` : '',
point ? `lat=${point.lat}` : '',
draft.entity_address_name ? `name=${encodeURIComponent(draft.entity_address_name)}` : '',
].filter(Boolean).join('&')
Taro.navigateTo({
url: query ? `/pages/map/index?${query}` : '/pages/map/index',
})
}
return ( return (
<View className='index'> <View className='check-page'>
{ <View className='check-hero'>
url && <WebView src={url}></WebView> <Image className='check-hero__bg' src={BANNER_BG} mode='aspectFill' />
} <Text className='check-hero__title'>{PAGE_TITLE}</Text>
</View>
<ContactEntry onClick={() => setShowContact(true)} />
<View className='check-card'>
<View className='check-card__header'>
<Image className='check-card__header-bg' src={CARD_HEADER} mode='scaleToFill' />
<Text className='check-card__header-title'></Text>
</View>
<View className='check-card__shell'>
<View className='check-card__body'>
<Text className='check-card__note'></Text>
<View className='check-card__label'>
<Image className='check-card__label-icon' src={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} />
<Text></Text>
<Text className='check-card__required'>*</Text>
</View>
<Input
className='check-card__input'
type='number'
maxlength={12}
placeholder='请输入联系电话'
value={inputPhone}
onInput={(event) => setInputPhone(event.detail.value.replace(/\D/g, ''))}
/>
<Button
className='check-card__button'
loading={checking}
disabled={!ready || checking}
onClick={() => void queryOrders(inputPhone)}
>
</Button>
</View>
</View>
</View>
{searched ? (
<View className='check-card'>
<View className='check-card__header'>
<Image className='check-card__header-bg' src={CARD_HEADER} mode='scaleToFill' />
<Text className='check-card__header-title'></Text>
</View>
<View className='check-card__shell'>
<View className='check-card__list'>
{orders.length ? orders.map((order, index) => (
<OrderCard
key={order.order_id || order.out_trade_no || index}
order={order}
first={index === 0}
onCopy={() => {
if (!order.out_trade_no) return
Taro.setClipboardData({ data: String(order.out_trade_no) })
}}
onViewDetail={() => setDetailOrder(order)}
onResubmit={() => {
setMapResult(null)
setResubmitOrder(order)
}}
/>
)) : (
<View className='check-card__empty'>
<Text></Text>
</View>
)}
</View>
</View>
</View>
) : null}
{detailOrder ? (
<View className='detail-mask' onClick={() => setDetailOrder(null)}>
<View className='detail-dialog' onClick={(event) => event.stopPropagation()}>
<Text className='detail-dialog__title'></Text>
<InfoLine label={`${ENTITY_LABELS.entityName}`} value={detailOrder.entity_name || '-'} />
<InfoLine label='联系电话:' value={detailOrder.entity_phone || '-'} />
<InfoLine label={`${ENTITY_LABELS.entityAddress}`} value={getOrderAddressName(detailOrder) || '-'} />
<InfoLine label={`${ENTITY_LABELS.entityMapLocation}`} value={detailOrder.extra?.entity_address || '-'} />
<InfoLine label='办理说明:' value='各大平台门店管理审核时间一般为1-7个工作日审核通过后可在对应地图平台查询。' />
<Button className='detail-dialog__close' onClick={() => setDetailOrder(null)}></Button>
</View>
</View>
) : null}
<ContactModal
open={showContact}
contact={contact}
onCancel={() => setShowContact(false)}
/>
<ResubmitDrawer
open={Boolean(resubmitOrder)}
order={resubmitOrder}
mapResult={mapResult}
onCancel={() => {
setResubmitOrder(null)
setMapResult(null)
}}
onChooseMap={handleChooseMap}
onSuccess={() => {
setResubmitOrder(null)
setMapResult(null)
void queryOrders(inputPhone)
}}
/>
</View>
)
}
function OrderCard ({
order,
first,
onCopy,
onViewDetail,
onResubmit,
}: {
order: OrderLike
first: boolean
onCopy: () => void
onViewDetail: () => void
onResubmit: () => void
}) {
const status = getStatusMeta(order.process_status, order.process_status_name)
const goodsIcons = getGoodsIcons(order)
const showDetail = order.process_status === '2'
const payIcon = PAY_ICON[order.pay_type]
return (
<View className={`order-card ${first ? 'order-card--first' : ''}`}>
<View className='order-card__head'>
<View className='order-card__goods'>
{goodsIcons.map((icon: string, index: number) => (
<Image className='order-card__goods-icon' key={`${icon}-${index}`} src={icon} />
))}
<Text className='order-card__goods-name'>{order.goods_name || '-'}</Text>
</View>
<View className='order-card__status' style={{ color: status.color, background: status.bg }}>
<Text>{status.result}</Text>
</View>
</View>
{order.reject_reason ? (
<View className='order-card__reject'>{order.reject_reason}</View>
) : null}
<InfoLine label={`${ENTITY_LABELS.entityName}`} value={order.entity_name || '-'} />
<InfoLine label='联系电话:' value={order.entity_phone || '-'} />
<InfoLine label={`${ENTITY_LABELS.entityAddress}`} value={getOrderAddressName(order) || '-'} />
<InfoLine label={`${ENTITY_LABELS.entityMapLocation}`} value={order.extra?.entity_address || '-'} />
<InfoLine label='订单时间:' value={order.create_time || order.pay_time || '-'} />
<View className='order-card__row'>
<Text className='order-card__label'>ID</Text>
<Text className='order-card__value order-card__value--id'>{order.out_trade_no || '-'}</Text>
{order.out_trade_no ? (
<Image className='order-card__copy' src={COPY_ICON} onClick={onCopy} />
) : null}
</View>
<View className='order-card__row'>
<Text className='order-card__label'></Text>
{payIcon ? <Image className='order-card__pay-icon' src={payIcon} /> : null}
<Text className='order-card__fee'>{order.total_fee || '-'}</Text>
</View>
<View className='order-card__actions'>
{showDetail ? (
<View className='order-card__action' onClick={onViewDetail}></View>
) : null}
<View className='order-card__action' onClick={onResubmit}></View>
</View>
</View>
)
}
function InfoLine ({ label, value }: { label: string, value: string }) {
return (
<View className='order-card__row'>
<Text className='order-card__label'>{label}</Text>
<Text className='order-card__value'>{value}</Text>
</View> </View>
) )
} }

View File

@ -0,0 +1,331 @@
import { Button, Image, Input, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useEffect, useState } from 'react'
import { uploadImage } from '@/api/user'
import { updateOrderExtra } from '@/api/pay'
import { ENTITY_LABELS, HOME_ICON_BASE, MAP_ASSET_BASE } from '@/lib/cdn'
import { isValidPhone } from '@/lib/phone'
import {
buildResubmitPayload,
formatMapCoordinate,
getInitialPhone2,
getOrderAddressName,
getOrderExtraImages,
getOrderMapAddress,
parseCoordinate,
type OrderLike,
type ResubmitValues,
type UploadedImage,
} from '@/lib/order'
import type { MapPickerResult } from '@/pages/map/picker'
type Props = {
open: boolean
order: OrderLike | null
mapResult?: MapPickerResult | null
onCancel: () => void
onChooseMap: (draft: ResubmitValues) => void
onSuccess: () => void
}
function FieldLabel ({ icon, text, required }: { icon: string, text: string, required?: boolean }) {
return (
<View className='resubmit__label'>
<Image className='resubmit__label-icon' src={icon} />
<Text>{text}</Text>
{required ? <Text className='resubmit__required'>*</Text> : null}
</View>
)
}
function UploadField ({
label,
values,
multiple = false,
uploading,
progress,
onChange,
onRemove,
}: {
label: string
values: UploadedImage[]
multiple?: boolean
uploading: boolean
progress: number
onChange: (filePaths: string[]) => void
onRemove: (index: number) => void
}) {
const showUpload = multiple || values.length === 0
const handleChoose = async () => {
const res = await Taro.chooseImage({
count: multiple ? 6 : 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
})
if (res.tempFilePaths?.length) {
onChange(res.tempFilePaths)
}
}
return (
<View className='resubmit__upload-list'>
{values.map((value, index) => (
<View className='resubmit__upload-item' key={`${value.id}-${index}`}>
<Image className='resubmit__upload-image' src={value.preview || value.url} mode='aspectFill' />
<View className='resubmit__upload-remove' onClick={() => onRemove(index)}>
<Text>×</Text>
</View>
</View>
))}
{showUpload ? (
<View className='resubmit__upload-slot' onClick={() => void handleChoose()}>
<Image className='resubmit__upload-plus' src={`${MAP_ASSET_BASE}/pay-success-upload.png`} />
<Text className='resubmit__upload-text'>
{uploading ? `上传中 ${Math.round(progress)}%` : (multiple && values.length > 0 ? '继续上传' : `请上传${label}`)}
</Text>
</View>
) : null}
</View>
)
}
export default function ResubmitDrawer ({
open,
order,
mapResult,
onCancel,
onChooseMap,
onSuccess,
}: Props) {
const [values, setValues] = useState<ResubmitValues>({})
const [storefrontImages, setStorefrontImages] = useState<UploadedImage[]>([])
const [licenseImages, setLicenseImages] = useState<UploadedImage[]>([])
const [uploadingField, setUploadingField] = useState<'storefront' | 'license' | null>(null)
const [uploadProgress, setUploadProgress] = useState(0)
const [formError, setFormError] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!order || !open) return
const mapAddress = mapResult ? formatMapCoordinate(mapResult) : ''
const mapAddressName = mapResult?.address || mapResult?.name || ''
setValues({
entity_name: values.entity_name || order.entity_name || mapResult?.name || '',
entity_address_name: mapResult ? mapAddressName || getOrderAddressName(order) : values.entity_address_name || getOrderAddressName(order),
entity_address: mapResult ? mapAddress : values.entity_address || getOrderMapAddress(order),
entity_phone: values.entity_phone || order.entity_phone || '',
entity_phone2: values.entity_phone2 || getInitialPhone2(order),
})
if (storefrontImages.length === 0) {
setStorefrontImages(getOrderExtraImages(order, ['entity_storefront_image', 'storefront_image']))
}
if (licenseImages.length === 0) {
setLicenseImages(getOrderExtraImages(order, ['entity_business_license_image', 'business_license_image']).slice(0, 1))
}
setFormError('')
// Only seed from the current order / returned map point.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, order, mapResult])
const updateField = <K extends keyof ResubmitValues>(field: K, value: ResubmitValues[K]) => {
setValues((current) => ({ ...current, [field]: value }))
}
const handleUpload = async (field: 'storefront' | 'license', filePaths: string[]) => {
setUploadingField(field)
setUploadProgress(0)
setFormError('')
try {
const images: UploadedImage[] = []
for (const [index, filePath] of filePaths.entries()) {
const uploaded = await uploadImage(filePath, (percent) => {
setUploadProgress(((index + percent / 100) / filePaths.length) * 100)
})
images.push({
id: uploaded.id,
url: uploaded.url,
preview: filePath,
})
}
if (field === 'storefront') {
setStorefrontImages((current) => [...current, ...images])
} else {
setLicenseImages(images.slice(0, 1))
}
} catch (error) {
setFormError(error instanceof Error ? error.message : '图片上传失败,请稍后重试')
} finally {
setUploadingField(null)
setUploadProgress(0)
}
}
const handleSubmit = async () => {
if (!order || submitting) return
if (uploadingField) {
setFormError('图片正在上传,请稍候')
return
}
if (!values.entity_name?.trim()) {
Taro.showToast({ title: `请输入${ENTITY_LABELS.entityName}`, icon: 'none' })
return
}
if (!values.entity_address_name?.trim()) {
Taro.showToast({ title: `请输入${ENTITY_LABELS.entityAddress}`, icon: 'none' })
return
}
if (!values.entity_address?.trim()) {
Taro.showToast({ title: `请选择${ENTITY_LABELS.entityMapLocation}`, icon: 'none' })
return
}
if (!isValidPhone(values.entity_phone || '')) {
Taro.showToast({ title: '请填写正确的联系电话1', icon: 'none' })
return
}
if (!isValidPhone(values.entity_phone2 || '')) {
Taro.showToast({ title: '请填写正确的联系电话2', icon: 'none' })
return
}
if (storefrontImages.length === 0) {
Taro.showToast({ title: '请上传门头照片', icon: 'none' })
return
}
setSubmitting(true)
setFormError('')
try {
await updateOrderExtra(buildResubmitPayload({
order,
values,
storefrontImages,
licenseImages,
}))
Taro.showToast({ title: '重新提交成功', icon: 'success' })
onSuccess()
} catch {
Taro.showToast({ title: '重新提交失败,请稍后再试', icon: 'none' })
} finally {
setSubmitting(false)
}
}
if (!open || !order) return null
const mapPoint = parseCoordinate(values.entity_address)
return (
<View className='resubmit-mask'>
<View className='resubmit'>
<View className='resubmit__close' onClick={onCancel}>
<Text className='resubmit__close-text'>×</Text>
</View>
<Text className='resubmit__title'></Text>
{order.reject_reason ? (
<View className='resubmit__reject'>{order.reject_reason}</View>
) : null}
<View className='resubmit__form'>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_marker@2x.png`} text={ENTITY_LABELS.entityName} required />
<Input
className='resubmit__input'
value={values.entity_name}
placeholder='请输入店铺/公司名称'
onInput={(event) => updateField('entity_name', event.detail.value)}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location@2x.png`} text={ENTITY_LABELS.entityAddress} required />
<Input
className='resubmit__input'
value={values.entity_address_name}
placeholder='请输入实际经营地址'
onInput={(event) => updateField('entity_address_name', event.detail.value)}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location_lnglat@2x.png`} text={ENTITY_LABELS.entityMapLocation} required />
<View
className='resubmit__map-input'
onClick={() => onChooseMap({
...values,
entity_address: mapPoint ? formatMapCoordinate(mapPoint) : values.entity_address,
})}
>
<Text className={values.entity_address ? 'resubmit__map-value' : 'resubmit__map-placeholder'}>
{values.entity_address || '请选择地图位置'}
</Text>
<Image className='resubmit__map-arrow' src={`${MAP_ASSET_BASE}/icon_input_arrow@2x.png`} />
</View>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='联系电话1' required />
<Input
className='resubmit__input'
type='number'
maxlength={12}
value={values.entity_phone}
placeholder='请输入'
onInput={(event) => updateField('entity_phone', event.detail.value.replace(/\D/g, ''))}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='联系电话2' required />
<Input
className='resubmit__input'
type='number'
maxlength={12}
value={values.entity_phone2}
placeholder='请输入'
onInput={(event) => updateField('entity_phone2', event.detail.value.replace(/\D/g, ''))}
/>
</View>
<Text className='resubmit__tip'>1-7</Text>
<View className='resubmit__field'>
<FieldLabel icon={`${MAP_ASSET_BASE}/pay-success-storefront.png`} text='门头照片' required />
<UploadField
label='门头照片'
values={storefrontImages}
multiple
uploading={uploadingField === 'storefront'}
progress={uploadProgress}
onChange={(paths) => void handleUpload('storefront', paths)}
onRemove={(index) => setStorefrontImages((current) => current.filter((_, itemIndex) => itemIndex !== index))}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${MAP_ASSET_BASE}/pay-success-license.png`} text='营业执照' />
<UploadField
label='营业执照'
values={licenseImages}
uploading={uploadingField === 'license'}
progress={uploadProgress}
onChange={(paths) => void handleUpload('license', paths)}
onRemove={() => setLicenseImages([])}
/>
</View>
{formError ? <Text className='resubmit__error'>{formError}</Text> : null}
<Button
className='resubmit__submit'
loading={submitting}
disabled={Boolean(uploadingField)}
onClick={() => void handleSubmit()}
>
</Button>
</View>
</View>
</View>
)
}

View File

@ -1,305 +0,0 @@
/* stylelint-disable selector-class-pattern -- The project requires BEM class names. */
.landing-page {
--landing-green: #07c160;
--landing-green-dark: #06ae56;
--landing-canvas: #f2f7fb;
--landing-surface: #fff;
--landing-ink: #17181a;
--landing-muted: #606772;
--landing-placeholder: #959ba3;
--landing-border: #c7ccd2;
--landing-error: #d94242;
min-height: 100vh;
padding: 54px 30px calc(38px + env(safe-area-inset-bottom));
overflow-x: hidden;
box-sizing: border-box;
color: var(--landing-ink);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--landing-canvas);
}
.landing-page__intro {
padding: 0 10px;
}
.landing-page__title {
display: block;
font-size: 48px;
font-weight: 700;
line-height: 1.35;
letter-spacing: -1px;
}
.landing-page__description {
display: block;
margin-top: 18px;
color: var(--landing-muted);
font-size: 28px;
line-height: 1.65;
}
.landing-page__notice {
display: block;
margin-top: 14px;
color: var(--landing-muted);
font-size: 24px;
line-height: 1.5;
}
.landing-page__form {
margin-top: 30px;
padding: 28px;
border: 1px solid #d9dde1;
border-radius: 14px;
box-sizing: border-box;
background: var(--landing-surface);
box-shadow: 0 8px 28px rgb(40 65 86 / 5%);
}
.landing-page__field + .landing-page__field {
margin-top: 26px;
}
.landing-page__label {
display: block;
margin-bottom: 12px;
font-size: 28px;
font-weight: 600;
line-height: 1.4;
}
.landing-page__service-types {
display: flex;
gap: 12px;
}
.landing-page__service-type {
display: flex;
height: 72px;
flex: 1;
align-items: center;
justify-content: center;
border: 1px solid var(--landing-border);
border-radius: 10px;
box-sizing: border-box;
font-size: 28px;
background: var(--landing-surface);
}
.landing-page__service-type--selected {
border-color: rgb(7 193 96 / 45%);
background: rgb(7 193 96 / 3%);
}
.landing-page__radio {
display: flex;
align-items: center;
gap: 12px;
}
.landing-page__input,
.landing-page__textarea {
width: 100%;
border: 1px solid var(--landing-border);
border-radius: 10px;
box-sizing: border-box;
color: var(--landing-ink);
font-size: 28px;
background: var(--landing-surface);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.landing-page__input {
height: 72px;
padding: 0 22px;
}
.landing-page__textarea {
height: 116px;
padding: 18px 22px;
line-height: 1.5;
}
.landing-page__input:focus,
.landing-page__textarea:focus {
border-color: var(--landing-green);
box-shadow: 0 0 0 3px rgb(7 193 96 / 10%);
}
.landing-page__input--error {
border-color: var(--landing-error);
}
.landing-page__placeholder {
color: var(--landing-placeholder);
}
.landing-page__error,
.landing-page__agreement-error {
display: block;
margin-top: 8px;
color: var(--landing-error);
font-size: 22px;
line-height: 1.4;
}
.landing-page__agreement {
display: flex;
align-items: center;
margin: 26px 18px 0;
font-size: 24px;
line-height: 1.5;
}
.landing-page__checkbox {
margin-right: 10px;
transform: scale(0.88);
transform-origin: left center;
}
.landing-page__agreement-text {
color: var(--landing-ink);
}
.landing-page__link {
color: var(--landing-green);
}
.landing-page__agreement-error {
margin-right: 18px;
margin-left: 18px;
}
.landing-page__submit {
height: 84px;
margin: 24px 10px 0;
padding: 0;
border: 0;
border-radius: 12px;
color: #fff;
font-size: 32px;
font-weight: 700;
line-height: 84px;
background: var(--landing-green);
box-shadow: none;
}
.landing-page__submit::after {
border: 0;
}
.landing-page__submit--pressed {
background: var(--landing-green-dark);
transform: scale(0.99);
}
.landing-page__footer {
display: flex;
align-items: center;
margin-top: 28px;
flex-direction: column;
color: var(--landing-muted);
font-size: 22px;
line-height: 1.75;
}
.landing-page__document-mask {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
align-items: flex-end;
padding-top: 80px;
box-sizing: border-box;
background: rgb(0 0 0 / 48%);
}
.landing-page__document {
display: flex;
width: 100%;
max-height: 88vh;
padding-bottom: env(safe-area-inset-bottom);
overflow: hidden;
border-radius: 28px 28px 0 0;
box-sizing: border-box;
flex-direction: column;
background: var(--landing-surface);
}
.landing-page__document-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28px 30px 22px;
border-bottom: 1px solid #e5e7e9;
}
.landing-page__document-title,
.landing-page__document-date,
.landing-page__document-heading,
.landing-page__document-copy {
display: block;
}
.landing-page__document-title {
font-size: 34px;
font-weight: 700;
line-height: 1.4;
}
.landing-page__document-date {
margin-top: 4px;
color: var(--landing-muted);
font-size: 20px;
line-height: 1.4;
}
.landing-page__document-close {
height: 60px;
margin: 0;
padding: 0 24px;
border: 0;
border-radius: 10px;
color: var(--landing-green-dark);
font-size: 24px;
line-height: 60px;
background: rgb(7 193 96 / 8%);
}
.landing-page__document-close::after {
border: 0;
}
.landing-page__document-body {
height: 860px;
padding: 4px 30px 36px;
box-sizing: border-box;
}
.landing-page__document-section {
padding-top: 26px;
}
.landing-page__document-heading {
font-size: 27px;
font-weight: 600;
line-height: 1.5;
}
.landing-page__document-copy {
margin-top: 10px;
color: var(--landing-muted);
font-size: 24px;
line-height: 1.75;
text-align: justify;
}
@media (prefers-reduced-motion: reduce) {
.landing-page__input,
.landing-page__textarea,
.landing-page__submit {
transition: none;
}
}

View File

@ -1,294 +0,0 @@
import { Button, Checkbox, CheckboxGroup, Input, Radio, RadioGroup, ScrollView, Text, Textarea, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import './index.scss'
type ConsultationType = 'operation' | 'brand'
type DocumentType = 'privacy' | 'service'
type ConsultationForm = {
consultationType: ConsultationType
businessName: string
businessCategory: string
contactName: string
contactPhone: string
description: string
}
type FormErrors = Partial<Record<keyof ConsultationForm | 'agreement', string>>
type DocumentSection = {
heading: string
content: string
}
const CONTACT_PHONE = '4001166311'
const INITIAL_FORM: ConsultationForm = {
consultationType: 'operation',
businessName: '',
businessCategory: '',
contactName: '',
contactPhone: '',
description: '',
}
const DOCUMENTS: Record<DocumentType, { title: string, updatedAt: string, sections: DocumentSection[] }> = {
privacy: {
title: '隐私政策',
updatedAt: '更新日期2026年7月20日',
sections: [
{
heading: '一、我们处理的信息',
content: '当您使用咨询信息填写功能时,页面会处理您主动填写的企业或门店名称、经营类别、联系人姓名、联系电话和咨询内容。我们不会要求您提供身份证件、精确位置、通讯录、相册或其他与咨询无关的信息。',
},
{
heading: '二、处理目的和方式',
content: '上述信息仅用于在当前页面核对咨询资料是否填写完整。当前版本没有服务器提交接口,不会将表单内容上传、保存或提供给第三方。关闭或刷新页面后,已填写内容不会由本小程序留存。',
},
{
heading: '三、权限与第三方共享',
content: '本页面不会申请位置、相机、相册、麦克风或通讯录权限,也不会通过本页面向任何第三方共享、转让或公开披露您填写的信息。',
},
{
heading: '四、您的权利',
content: '您可以随时修改或清空尚未提交的内容,也可以不填写表单并直接退出页面。若您对个人信息保护有疑问,可在工作时间拨打页面底部客服电话联系我们。',
},
{
heading: '五、政策更新',
content: '如后续开通在线提交或存储功能,我们会在收集信息前更新本政策,明确告知收集范围、使用目的、保存期限和保护措施,并重新取得您的同意。',
},
],
},
service: {
title: '用户服务协议',
updatedAt: '生效日期2026年7月20日',
sections: [
{
heading: '一、服务内容',
content: '本页面提供门店经营信息梳理、品牌展示建议和宣传资料制作等企业咨询信息填写服务。本服务不提供任何平台的官方认证、官方代办或结果承诺。',
},
{
heading: '二、使用方式',
content: '您应根据实际需求填写企业或门店信息。页面校验完成后,您可自行选择是否拨打客服电话进行正式咨询。当前版本不会在线提交或保存表单内容。',
},
{
heading: '三、用户责任',
content: '您应保证提供的信息真实、合法且不侵犯他人权益,不得利用本服务提交违法、虚假、侵权或与企业咨询无关的内容。',
},
{
heading: '四、服务费用',
content: '信息填写和电话咨询不收取费用。如后续服务产生费用,工作人员会在服务开始前说明具体项目、价格和退款规则,由您自主决定是否购买。',
},
{
heading: '五、免责声明',
content: '咨询建议仅供经营决策参考,实际执行效果会受到经营环境、市场变化和用户自身情况影响。我们不会承诺特定经营结果。',
},
{
heading: '六、联系我们',
content: '如您对服务内容或本协议有疑问可在工作日9:00-18:00拨打客服电话400-116-6311。',
},
],
},
}
const PHONE_PATTERN = /^1[3-9]\d{9}$/
const validateForm = (form: ConsultationForm, agreed: boolean) => {
const errors: FormErrors = {}
if (!form.businessName.trim()) errors.businessName = '请输入企业或门店名称'
if (!form.businessCategory.trim()) errors.businessCategory = '请输入经营类别'
if (!form.contactName.trim()) errors.contactName = '请输入联系人姓名'
if (!form.contactPhone.trim()) {
errors.contactPhone = '请输入联系电话'
} else if (!PHONE_PATTERN.test(form.contactPhone.trim())) {
errors.contactPhone = '请输入正确的手机号码'
}
if (!form.description.trim()) errors.description = '请描述您的咨询需求'
if (!agreed) errors.agreement = '请阅读并同意隐私政策和用户服务协议'
return errors
}
export default function Landing () {
const [form, setForm] = useState<ConsultationForm>(INITIAL_FORM)
const [agreed, setAgreed] = useState(false)
const [errors, setErrors] = useState<FormErrors>({})
const [activeDocument, setActiveDocument] = useState<DocumentType | null>(null)
const updateField = <K extends keyof ConsultationForm>(field: K, value: ConsultationForm[K]) => {
setForm((current) => ({ ...current, [field]: value }))
setErrors((current) => ({ ...current, [field]: undefined }))
}
const handleSubmit = async () => {
const validationErrors = validateForm(form, agreed)
const firstError = Object.values(validationErrors)[0]
setErrors(validationErrors)
if (firstError) {
Taro.showToast({ title: firstError, icon: 'none' })
return
}
const result = await Taro.showModal({
title: '信息填写完成',
content: '当前版本不会上传或保存表单信息。如需正式咨询请拨打客服电话400-116-6311。',
cancelText: '稍后联系',
confirmText: '拨打客服',
confirmColor: '#07c160',
})
if (result.confirm) {
await Taro.makePhoneCall({ phoneNumber: CONTACT_PHONE })
}
}
const document = activeDocument ? DOCUMENTS[activeDocument] : null
return (
<View className='landing-page'>
<View className='landing-page__intro'>
<Text className='landing-page__title'>线</Text>
<Text className='landing-page__description'>
线
</Text>
<Text className='landing-page__notice'></Text>
</View>
<View className='landing-page__form'>
<View className='landing-page__field'>
<Text className='landing-page__label'></Text>
<RadioGroup
className='landing-page__service-types'
onChange={(event) => updateField('consultationType', event.detail.value as ConsultationType)}
>
<View className={`landing-page__service-type ${form.consultationType === 'operation' ? 'landing-page__service-type--selected' : ''}`}>
<Radio className='landing-page__radio' value='operation' checked={form.consultationType === 'operation'} color='#07c160'></Radio>
</View>
<View className={`landing-page__service-type ${form.consultationType === 'brand' ? 'landing-page__service-type--selected' : ''}`}>
<Radio className='landing-page__radio' value='brand' checked={form.consultationType === 'brand'} color='#07c160'></Radio>
</View>
</RadioGroup>
</View>
<View className='landing-page__field'>
<Text className='landing-page__label'></Text>
<Input
className={`landing-page__input ${errors.businessName ? 'landing-page__input--error' : ''}`}
value={form.businessName}
maxlength={50}
placeholder='请输入企业或门店名称'
placeholderClass='landing-page__placeholder'
onInput={(event) => updateField('businessName', event.detail.value)}
/>
{errors.businessName && <Text className='landing-page__error'>{errors.businessName}</Text>}
</View>
<View className='landing-page__field'>
<Text className='landing-page__label'></Text>
<Input
className={`landing-page__input ${errors.businessCategory ? 'landing-page__input--error' : ''}`}
value={form.businessCategory}
maxlength={40}
placeholder='例如:餐饮、零售、生活服务'
placeholderClass='landing-page__placeholder'
onInput={(event) => updateField('businessCategory', event.detail.value)}
/>
{errors.businessCategory && <Text className='landing-page__error'>{errors.businessCategory}</Text>}
</View>
<View className='landing-page__field'>
<Text className='landing-page__label'></Text>
<Input
className={`landing-page__input ${errors.contactName ? 'landing-page__input--error' : ''}`}
value={form.contactName}
maxlength={30}
placeholder='请输入联系人姓名'
placeholderClass='landing-page__placeholder'
onInput={(event) => updateField('contactName', event.detail.value)}
/>
{errors.contactName && <Text className='landing-page__error'>{errors.contactName}</Text>}
</View>
<View className='landing-page__field'>
<Text className='landing-page__label'></Text>
<Input
className={`landing-page__input ${errors.contactPhone ? 'landing-page__input--error' : ''}`}
value={form.contactPhone}
type='number'
maxlength={11}
placeholder='请输入手机号码'
placeholderClass='landing-page__placeholder'
onInput={(event) => updateField('contactPhone', event.detail.value)}
/>
{errors.contactPhone && <Text className='landing-page__error'>{errors.contactPhone}</Text>}
</View>
<View className='landing-page__field'>
<Text className='landing-page__label'></Text>
<Textarea
className={`landing-page__textarea ${errors.description ? 'landing-page__input--error' : ''}`}
value={form.description}
maxlength={300}
placeholder='请描述希望咨询的经营问题或资料制作需求'
placeholderClass='landing-page__placeholder'
onInput={(event) => updateField('description', event.detail.value)}
/>
{errors.description && <Text className='landing-page__error'>{errors.description}</Text>}
</View>
</View>
<CheckboxGroup
className='landing-page__agreement'
onChange={(event) => {
setAgreed(event.detail.value.includes('agreed'))
setErrors((current) => ({ ...current, agreement: undefined }))
}}
>
<Checkbox className='landing-page__checkbox' value='agreed' checked={agreed} color='#07c160' />
<Text className='landing-page__agreement-text'></Text>
<Text className='landing-page__link' onClick={() => setActiveDocument('privacy')}></Text>
<Text className='landing-page__agreement-text'></Text>
<Text className='landing-page__link' onClick={() => setActiveDocument('service')}></Text>
</CheckboxGroup>
{errors.agreement && <Text className='landing-page__agreement-error'>{errors.agreement}</Text>}
<Button className='landing-page__submit' hoverClass='landing-page__submit--pressed' onClick={handleSubmit}>
</Button>
<View className='landing-page__footer'>
<Text> 9:00-18:00</Text>
<Text>400-116-6311</Text>
<Text></Text>
</View>
{document && (
<View className='landing-page__document-mask'>
<View className='landing-page__document'>
<View className='landing-page__document-header'>
<View>
<Text className='landing-page__document-title'>{document.title}</Text>
<Text className='landing-page__document-date'>{document.updatedAt}</Text>
</View>
<Button className='landing-page__document-close' onClick={() => setActiveDocument(null)}></Button>
</View>
<ScrollView className='landing-page__document-body' scrollY>
{document.sections.map((section) => (
<View className='landing-page__document-section' key={section.heading}>
<Text className='landing-page__document-heading'>{section.heading}</Text>
<Text className='landing-page__document-copy'>{section.content}</Text>
</View>
))}
</ScrollView>
</View>
</View>
)}
</View>
)
}

View File

@ -1,7 +1,6 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '门店经营咨询', navigationBarTitleText: '选择位置',
navigationBarBackgroundColor: '#ffffff', navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black', navigationBarTextStyle: 'black',
backgroundColor: '#f2f7fb', disableScroll: true,
backgroundTextStyle: 'dark'
}) })

112
src/pages/map/index.scss Normal file
View File

@ -0,0 +1,112 @@
.map-page {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
background: #f5f5f5;
}
.map-page__map {
width: 100%;
height: 100%;
}
.map-page__pin {
position: absolute;
top: 46%;
left: 50%;
z-index: 2;
width: 28px;
height: 28px;
margin-left: -14px;
margin-top: -28px;
border: 6px solid #0261fc;
border-radius: 50% 50% 50% 0;
background: #fff;
transform: rotate(-45deg);
}
.map-page__search {
position: absolute;
top: 24px;
right: 24px;
left: 24px;
z-index: 2;
box-sizing: border-box;
height: 80px;
padding: 0 28px;
border-radius: 12px;
background: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
}
.map-page__search-text {
overflow: hidden;
color: #222;
font-size: 28px;
line-height: 40px;
white-space: nowrap;
text-overflow: ellipsis;
}
.map-page__locate {
position: absolute;
right: 24px;
bottom: 360px;
z-index: 2;
width: 96px;
height: 96px;
border-radius: 48px;
background: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
justify-content: center;
color: #0261fc;
font-size: 24px;
}
.map-page__panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
box-sizing: border-box;
padding: 28px 24px 48px;
border-radius: 24px 24px 0 0;
background: #fff;
}
.map-page__coord {
display: block;
color: #0261fc;
font-size: 28px;
line-height: 40px;
}
.map-page__address {
display: block;
margin-top: 8px;
color: #666;
font-size: 26px;
line-height: 36px;
}
.map-page__confirm {
margin-top: 24px;
height: 88px;
border: 0;
border-radius: 12px;
background: #0261fc;
color: #fff;
font-size: 32px;
font-weight: 700;
line-height: 88px;
}
.map-page__confirm::after {
border: 0;
}

193
src/pages/map/index.tsx Normal file
View File

@ -0,0 +1,193 @@
import { Button, Map, Text, View } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useRef, useState } from 'react'
import {
isRegionChangeEnd,
isUserRegionChange,
pickMapCenter,
} from '@/lib/geocode'
import { reverseGeocode } from '@/lib/reverseGeocode'
import { formatMapCoordinate } from '@/lib/order'
import { saveMapPickerResult, type MapPickerResult } from './picker'
import './index.scss'
const DEFAULT_CENTER = { lng: 116.397428, lat: 39.90923 }
const MAP_ID = 'picker-map'
function asNumber (value?: string) {
const next = Number(value)
return Number.isFinite(next) ? next : null
}
function decodeText (value?: string) {
if (!value) return ''
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function samePoint (left: { lng: number, lat: number }, lng: number, lat: number) {
return Math.abs(left.lng - lng) < 0.000001 && Math.abs(left.lat - lat) < 0.000001
}
export default function MapPage () {
const [center, setCenter] = useState(DEFAULT_CENTER)
const [selected, setSelected] = useState<MapPickerResult>(DEFAULT_CENTER)
const [locating, setLocating] = useState(false)
const [resolving, setResolving] = useState(false)
const geocodeSeq = useRef(0)
const applyPoint = (point: MapPickerResult, moveMap = false) => {
const next = {
lng: point.lng,
lat: point.lat,
name: point.name || '',
address: point.address || '',
}
setSelected(next)
if (moveMap) setCenter({ lng: next.lng, lat: next.lat })
if (!next.address && !next.name) {
void fillPlaceName(next.lng, next.lat)
}
}
const fillPlaceName = async (lng: number, lat: number) => {
const seq = geocodeSeq.current + 1
geocodeSeq.current = seq
setResolving(true)
try {
const place = await reverseGeocode(lng, lat)
if (seq !== geocodeSeq.current) return
if (!place.address && !place.name) return
setSelected((current) => {
if (!samePoint(current, lng, lat)) return current
return {
...current,
address: place.address || current.address,
name: place.name || current.name,
}
})
} catch {
// keep coordinates when geocode fails
} finally {
if (seq === geocodeSeq.current) setResolving(false)
}
}
const applyCenter = (lng: number, lat: number, moveMap = false) => {
applyPoint({ lng, lat }, moveMap)
}
const locateCurrent = async (silent = false) => {
if (!silent) setLocating(true)
try {
const location = await Taro.getLocation({ type: 'gcj02' })
applyCenter(location.longitude, location.latitude, true)
} catch {
if (!silent) {
Taro.showToast({ title: '定位失败,请检查定位权限', icon: 'none' })
}
} finally {
if (!silent) setLocating(false)
}
}
useLoad((options) => {
const lng = asNumber(options?.lng)
const lat = asNumber(options?.lat)
if (lng !== null && lat !== null) {
applyPoint({
lng,
lat,
name: decodeText(options?.name),
address: decodeText(options?.address),
}, true)
return
}
void locateCurrent(true)
})
const handleSearch = async () => {
try {
const result = await Taro.chooseLocation({})
applyPoint({
lng: result.longitude,
lat: result.latitude,
name: result.name,
address: result.address,
}, true)
} catch {
// user cancel
}
}
const handleRegionChange = (event: any) => {
if (!isRegionChangeEnd(event) || !isUserRegionChange(event)) return
const fromEvent = pickMapCenter(event)
if (fromEvent) {
applyCenter(fromEvent.lng, fromEvent.lat)
return
}
Taro.createMapContext(MAP_ID).getCenterLocation({
success (res) {
if (typeof res.longitude === 'number' && typeof res.latitude === 'number') {
applyCenter(res.longitude, res.latitude)
}
},
})
}
const handleConfirm = () => {
saveMapPickerResult(selected)
Taro.navigateBack()
}
const placeText = selected.name || selected.address || (resolving ? '正在识别位置…' : '滑动地图选择位置')
return (
<View className='map-page'>
<Map
id={MAP_ID}
className='map-page__map'
longitude={center.lng}
latitude={center.lat}
scale={16}
showLocation
enablePoi
onError={() => undefined}
onRegionChange={handleRegionChange}
onPoiTap={(event) => {
const { longitude, latitude, name } = event.detail || {}
if (typeof longitude === 'number' && typeof latitude === 'number') {
applyPoint({ lng: longitude, lat: latitude, name, address: name }, true)
}
}}
onTap={(event) => {
const { longitude, latitude } = event.detail || {}
if (typeof longitude === 'number' && typeof latitude === 'number') {
applyCenter(longitude, latitude, true)
}
}}
/>
<View className='map-page__pin' />
<View className='map-page__search' onClick={handleSearch}>
<Text className='map-page__search-text'>{placeText === '滑动地图选择位置' ? '搜索地点' : placeText}</Text>
</View>
<View className='map-page__locate' onClick={() => void locateCurrent()}>
<Text>{locating ? '定位中' : '定位'}</Text>
</View>
<View className='map-page__panel'>
<Text className='map-page__coord'>{formatMapCoordinate(selected)}</Text>
<Text className='map-page__address'>{placeText}</Text>
<Button className='map-page__confirm' onClick={handleConfirm}></Button>
</View>
</View>
)
}

31
src/pages/map/picker.ts Normal file
View File

@ -0,0 +1,31 @@
import Taro from '@tarojs/taro'
export type MapPickerResult = {
lng: number
lat: number
address?: string
name?: string
}
const RESULT_KEY = 'mapPickerResult'
export function saveMapPickerResult (result: MapPickerResult) {
try {
Taro.setStorageSync(RESULT_KEY, result)
} catch {
// ignore
}
}
export function consumeMapPickerResult (): MapPickerResult | null {
try {
const result = Taro.getStorageSync(RESULT_KEY)
Taro.removeStorageSync(RESULT_KEY)
if (!result || typeof result.lng !== 'number' || typeof result.lat !== 'number') {
return null
}
return result
} catch {
return null
}
}

View File

@ -1,3 +0,0 @@
export default definePageConfig({
navigationBarTitleText: '支付'
})

View File

@ -1,114 +0,0 @@
.pay-page {
min-height: 100vh;
padding: 48px 32px;
box-sizing: border-box;
background: #f6f7fb;
}
.pay-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
padding: 40px 28px;
border-radius: 8px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e8ebf1;
}
.pay-card--success {
border-color: #17a34a;
}
.pay-card--failed {
border-color: #dc2626;
}
.pay-card--canceled {
border-color: #f59e0b;
}
.pay-icon {
width: 96px;
height: 96px;
margin-bottom: 24px;
border-radius: 16px;
}
.pay-label {
color: #667085;
font-size: 28px;
line-height: 40px;
}
.pay-amount {
margin-top: 16px;
color: #ff2626;
font-size: 72px;
font-weight: 700;
line-height: 88px;
}
.pay-status {
margin-top: 20px;
color: #344054;
font-size: 28px;
line-height: 40px;
text-align: center;
}
.pay-info {
margin-top: 24px;
padding: 8px 28px;
border-radius: 8px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e8ebf1;
}
.pay-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 24px 0;
border-bottom: 1px solid #edf0f5;
}
.pay-row:last-child {
border-bottom: 0;
}
.pay-row__label {
flex: 0 0 144px;
color: #667085;
font-size: 26px;
line-height: 38px;
}
.pay-row__value {
flex: 1;
min-width: 0;
color: #111827;
font-size: 26px;
line-height: 38px;
text-align: right;
word-break: break-all;
}
.pay-button {
margin-top: 32px;
height: 88px;
border-radius: 8px;
color: #ffffff;
font-size: 32px;
line-height: 88px;
background: #07c160;
}
.pay-button[disabled] {
color: rgba(255, 255, 255, 0.8);
background: #84d8a9;
}

View File

@ -1,208 +0,0 @@
import { Button, Image, Text, View } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { decodeOrderParam } from '@/utils/base64'
import './index.scss'
type PayStatus = 'loading' | 'paying' | 'success' | 'failed' | 'canceled'
type OrderInfo = {
qrcode?: string | null
orderId: string
jumpUrl?: string | null
qrcodeUrl?: string | null
payFee?: number
appId?: string
timeStamp?: string
nonceStr?: string
package?: string
signType?: string
paySign?: string
icon?: string
outTradeNo?: string
}
type FormInfo = {
goods_type: string
entity_phone
extra: {
entity_address: string
entity_address_name: string
}
}
type SelectedPlan = {
name: string
icon: string[]
}
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'object' && error && 'errMsg' in error) {
return String((error as { errMsg?: string }).errMsg)
}
return '支付失败'
}
const hasPayParams = (info: OrderInfo) =>
Boolean(info.timeStamp && info.nonceStr && info.package && info.paySign)
export default function Index() {
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null)
const [formInfo, setFormInfo] = useState<FormInfo | null>(null)
const [selectedPlan, setSelectedPlan] = useState<SelectedPlan | null>(null)
const [status, setStatus] = useState<PayStatus>('loading')
const [message, setMessage] = useState('正在准备订单')
const [loading, setLoading] = useState(false)
const requestPay = async (info: OrderInfo) => {
if (loading) {
return
}
setLoading(true)
setStatus('paying')
setMessage('正在拉起微信支付')
try {
if (Taro.getEnv() === Taro.ENV_TYPE.WEAPP) {
const res = await new Promise((resolve, reject) => {
Taro.requestPayment({
timeStamp: info.timeStamp as string,
nonceStr: info.nonceStr as string,
package: info.package as string,
signType: (info.signType as 'RSA') || 'RSA',
paySign: info.paySign as string,
success(r) {
console.log('success', r)
resolve(r)
},
fail(err) {
console.log('fail', err)
reject(err)
},
})
})
console.log("res", res)
} else {
await new Promise((resolve) => setTimeout(resolve, 800))
}
setStatus('success')
setMessage('支付成功')
Taro.showToast({
title: '支付成功',
icon: 'success'
})
// 返回 WebViewH5让公众号页面刷新订单状态
setTimeout(() => {
Taro.navigateBack()
}, 800)
} catch (error) {
const errorMessage = getErrorMessage(error)
const isCancel = errorMessage.includes('cancel')
setStatus(isCancel ? 'canceled' : 'failed')
setMessage(isCancel ? '已取消支付,可重新发起' : errorMessage)
Taro.showToast({
title: isCancel ? '已取消支付' : '支付失败',
icon: 'none'
})
} finally {
setLoading(false)
}
}
useLoad(() => {
const { order, formInfo, selectedPlanInfo } = Taro.getCurrentInstance().router?.params ?? {}
const info = decodeOrderParam<OrderInfo>(order)
const form = decodeOrderParam<FormInfo>(formInfo)
const selectedPlan = decodeOrderParam<SelectedPlan>(selectedPlanInfo)
console.log("info", info)
console.log("form", form)
console.log("selectedPlan", selectedPlan)
if (!info || !hasPayParams(info)) {
setStatus('failed')
setMessage('订单信息异常,无法发起支付')
Taro.showToast({
title: '订单信息异常',
icon: 'none'
})
return
}
setOrderInfo(info)
setFormInfo(form)
setSelectedPlan(selectedPlan)
void requestPay(info)
})
return (
<View className='pay-page'>
<View className={`pay-card pay-card--${status}`}>
<Text className='pay-label'></Text>
<Text className='pay-amount'>
<Text style="font-size: 40rpx"></Text>{orderInfo?.payFee}
</Text>
<Text className='pay-status'>{message}</Text>
</View>
{orderInfo ? (
<View className='pay-info'>
<View className='pay-row'>
<Text className='pay-row__label' style="color:#000;font-weight:bold;"></Text>
</View>
<View className='pay-row'>
<Text className='pay-row__label'></Text>
<Text className='pay-row__value'>{orderInfo.outTradeNo}</Text>
</View>
<View className='pay-row'>
<Text className='pay-row__label'></Text>
<Text className='pay-row__value'>{orderInfo.timeStamp}</Text>
</View>
<View className='pay-row'>
<Text className='pay-row__label' style="color:#000;font-weight:bold;"></Text>
</View>
<View className='pay-row'>
<Text className='pay-row__label'></Text>
<View className='pay-row__value' style="display:flex;justify-content:flex-end;flex-flow:wrap;">
<View style="display:flex;flex-direction:row;align-items:center;">
{
selectedPlan?.icon.map((icon, index) => <Image style="width:40rpx;height:40rpx" key={index} src={icon} />)
}
</View>
<Text>{selectedPlan?.name}</Text>
</View>
</View>
<View className='pay-row'>
<Text className='pay-row__label'></Text>
<Text className='pay-row__value'>{formInfo?.extra?.entity_address_name}</Text>
</View>
<View className='pay-row'>
<Text className='pay-row__label'></Text>
<Text className='pay-row__value'>{formInfo?.extra?.entity_address}</Text>
</View>
<View className='pay-row'>
<Text className='pay-row__label'></Text>
<Text className='pay-row__value'>{formInfo?.entity_phone}</Text>
</View>
</View>
) : null}
{orderInfo ? (
<Button
className='pay-button'
loading={loading}
disabled={loading}
onClick={() => requestPay(orderInfo)}
>
{loading ? '支付中' : status === 'success' ? '支付完成' : '立即支付'}
</Button>
) : null}
</View>
)
}

View File

@ -1,60 +0,0 @@
const BASE64_CHARS =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
/**
* atobweapp atob
* base64 charCode
*/
function base64Decode(input: string): string {
// 去掉所有非 base64 字符(换行、空白等),并去掉 padding 用于循环
const cleaned = input.replace(/[^A-Za-z0-9+/]/g, '')
let output = ''
for (let i = 0; i < cleaned.length; i += 4) {
const c1 = BASE64_CHARS.indexOf(cleaned[i])
const c2 = BASE64_CHARS.indexOf(cleaned[i + 1])
const c3 = BASE64_CHARS.indexOf(cleaned[i + 2])
const c4 = BASE64_CHARS.indexOf(cleaned[i + 3])
const byte1 = (c1 << 2) | (c2 >> 4)
output += String.fromCharCode(byte1)
if (c3 !== -1) {
const byte2 = ((c2 & 15) << 4) | (c3 >> 2)
output += String.fromCharCode(byte2)
}
if (c4 !== -1) {
const byte3 = ((c3 & 3) << 6) | c4
output += String.fromCharCode(byte3)
}
}
return output
}
/**
* H5 utf8ToBase64
* utf8ToBase64(v) = btoa(unescape(encodeURIComponent(JSON.stringify(v))))
* base64 escape decodeURIComponent UTF-8
*
* URL H5 base64 encodeURIComponent query
* base64 '+'
*/
export function base64ToUtf8(raw: string): string {
const normalized = raw.replace(/ /g, '+')
return decodeURIComponent(escape(base64Decode(normalized)))
}
/**
* base64 JSON null
*/
export function decodeOrderParam<T>(raw?: string): T | null {
if (!raw) {
return null
}
try {
return JSON.parse(base64ToUtf8(raw)) as T
} catch {
return null
}
}

6
types/global.d.ts vendored
View File

@ -23,6 +23,12 @@ declare namespace NodeJS {
* @see https://taro-docs.jd.com/docs/next/env-mode-config#特殊环境变量-taro_app_id * @see https://taro-docs.jd.com/docs/next/env-mode-config#特殊环境变量-taro_app_id
*/ */
TARO_APP_ID: string TARO_APP_ID: string
/** 接口域名,开发/生产分别来自 .env.development / .env.production */
TARO_APP_API_ORIGIN: string
/** x-host 回退值 */
TARO_APP_API_HOST: string
/** 天地图逆地理编码 key用于地图选点回填地址名称 */
TARO_APP_TIANDITU_KEY: string
} }
} }

14
vitest.config.ts Normal file
View File

@ -0,0 +1,14 @@
import path from 'node:path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
})