diff --git a/.env.development b/.env.development index 1804f79..d231aa4 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,3 @@ -# 配置文档参考 https://taro-docs.jd.com/docs/next/env-mode-config -TARO_APP_ID="wx6d4f6f29c41aff93" +TARO_APP_API_ORIGIN=https://corp-test.batiao8.com +TARO_APP_API_HOST=nb.batiao8.com +TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198 diff --git a/.env.production b/.env.production index c192f7e..c83f994 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,3 @@ -# TARO_APP_ID="生产环境下的小程序 AppID" -TARO_APP_ID="wx6d4f6f29c41aff93" \ No newline at end of file +TARO_APP_API_ORIGIN=https://nb.zuom8.cn +TARO_APP_API_HOST=nb.zuom8.cn +TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198 diff --git a/AGENTS.md b/AGENTS.md index 5859b41..63ecd6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,17 +23,22 @@ Other targets follow `pnpm dev:` / `pnpm build:` (`alipay`, ## 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 `` pointing at the business H5 (`http://niubsw.com/...?mpCode=`). 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: - - The H5 detects it's inside the mini-program and calls `wxSdk.miniProgram.navigateTo({ url: '/pages/pay/index?order=&formInfo=' })`. - - 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. +**`pages/map/index` — 微信原生地图选点.** Tap / locate / `chooseLocation` search, returns GCJ-02 `lng,lat`. -**`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(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 diff --git a/package.json b/package.json index 757385a..e243176 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "dev:rn": "npm run build:rn -- --watch", "dev:qq": "npm run build:qq -- --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": { "development": [ @@ -47,48 +48,51 @@ "@babel/runtime": "^7.24.4", "@tarojs/components": "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-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-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/shared": "4.2.0", "@tarojs/taro": "4.2.0", - "@tarojs/plugin-framework-react": "4.2.0", - "@tarojs/react": "4.2.0", - "react-dom": "^18.0.0", - "react": "^18.0.0" + "crypto-js": "^4.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "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/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", + "@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", "@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-hooks": "^4.4.0", + "husky": "^9.1.7", + "lint-staged": "^16.1.2", + "postcss": "^8.5.6", "react-refresh": "^0.14.0", "sass": "^1.75.0", + "stylelint": "^16.4.0", + "stylelint-config-standard": "^38.0.0", + "terser": "^5.30.4", "typescript": "^5.4.5", - "postcss": "^8.5.6", - "@types/minimatch": "^5" + "vite": "^4.2.0", + "vitest": "^2.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32be937..b248af7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@tarojs/taro': 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) + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 react: specifier: ^18.0.0 version: 18.3.1 @@ -87,6 +90,9 @@ importers: '@tarojs/vite-runner': 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)) + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/minimatch': specifier: ^5 version: 5.1.2 @@ -141,6 +147,9 @@ importers: vite: specifier: ^4.2.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: @@ -1253,6 +1262,13 @@ packages: '@keyv/serialize@1.1.1': 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': resolution: {integrity: sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA==} @@ -1425,6 +1441,144 @@ packages: rollup: 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': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1927,6 +2081,9 @@ packages: '@types/conventional-commits-parser@5.0.2': 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': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2056,6 +2213,35 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -2171,6 +2357,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -2326,6 +2516,10 @@ packages: buffer@5.7.1: 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: resolution: {integrity: sha512-vag0O2LKZ/najSoUwDbVlnlCFvhBE/7mGTY2B5FgCBDcRD+oVV1HYTOwM6JZfMg/hIcM6IwnTZ1uQQL5/X3xIQ==} @@ -2368,6 +2562,10 @@ packages: resolution: {integrity: sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==} engines: {node: '>=4'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} engines: {node: '>=8'} @@ -2386,6 +2584,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -2547,6 +2749,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 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: resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} engines: {node: '>=12'} @@ -2636,6 +2842,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -2759,6 +2969,9 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2910,6 +3123,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2917,6 +3133,10 @@ packages: eventemitter3@5.0.4: 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: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} engines: {node: '>=0.10.0'} @@ -3748,6 +3968,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@1.1.4: resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} @@ -4069,6 +4292,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} 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: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -4408,6 +4638,11 @@ packages: engines: {node: '>=14.18.0', npm: '>=8.0.0'} 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: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -4526,6 +4761,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4590,6 +4828,12 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} 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: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4753,10 +4997,28 @@ packages: tiny-case@1.0.3: 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: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} 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: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -4920,6 +5182,11 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} 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: resolution: {integrity: sha512-9h3iaVs0bqnqZOM5YHJXGHqdC5VAVlTZ2ARYsuNpzhEJUHmFqXY7dAK4ZFpjEQ4WLFKcaN8yWbczr81n01U4sQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4954,6 +5221,62 @@ packages: terser: 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: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -4993,6 +5316,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} @@ -6303,6 +6631,9 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/triples@1.2.0': {} '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': @@ -6435,6 +6766,81 @@ snapshots: optionalDependencies: 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': {} '@sideway/address@4.1.5': @@ -7028,6 +7434,8 @@ snapshots: dependencies: '@types/node': 25.9.2 + '@types/crypto-js@4.2.2': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -7201,6 +7609,46 @@ snapshots: transitivePeerDependencies: - 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: dependencies: jsonparse: 1.3.1 @@ -7344,6 +7792,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} async-function@1.0.0: {} @@ -7529,6 +7979,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + cac@6.7.14: {} + cacheable-request@2.1.4: dependencies: clone-response: 1.0.2 @@ -7601,6 +8053,14 @@ snapshots: tunnel-agent: 0.6.0 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: dependencies: ansi-styles: 4.3.0 @@ -7630,6 +8090,8 @@ snapshots: chardet@2.1.1: {} + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -7801,6 +8263,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + css-functions-list@3.3.3: {} css-tree@3.2.1: @@ -7888,6 +8352,8 @@ snapshots: dedent@1.7.2: {} + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -8079,6 +8545,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -8368,10 +8836,16 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} eventemitter3@5.0.4: {} + expect-type@1.4.0: {} + ext-list@2.2.2: dependencies: mime-db: 1.54.0 @@ -9216,6 +9690,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lower-case@1.1.4: {} lower-case@2.0.2: @@ -9526,6 +10002,10 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} + pend@1.2.0: {} picocolors@1.1.1: {} @@ -9849,6 +10329,38 @@ snapshots: optionalDependencies: 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-parallel@1.2.0: @@ -10005,6 +10517,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -10065,6 +10579,10 @@ snapshots: split2@4.2.0: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -10290,8 +10808,18 @@ snapshots: tiny-case@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyexec@1.2.4: {} + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -10450,6 +10978,24 @@ snapshots: 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)): dependencies: chokidar: 3.6.0 @@ -10469,6 +11015,52 @@ snapshots: sass: 1.100.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: dependencies: defaults: 1.0.4 @@ -10532,6 +11124,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wildcard@2.0.1: {} word-wrap@1.2.5: {} diff --git a/src/api/pay.ts b/src/api/pay.ts new file mode 100644 index 0000000..8c379bd --- /dev/null +++ b/src/api/pay.ts @@ -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) { + return request({ + url: '/api/h5/order', + method: 'PUT', + data, + }) +} diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..d362ee9 --- /dev/null +++ b/src/api/user.ts @@ -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 = {}) { + return request({ + url: '/api/user/config', + method: 'GET', + params, + }) +} + +export function initCorp (params: Record = {}) { + 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), + } +} diff --git a/src/app.config.ts b/src/app.config.ts index 294ede1..1184111 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,13 +1,21 @@ export default defineAppConfig({ pages: [ 'pages/index/index', - 'pages/landing/index', - 'pages/pay/index', + 'pages/map/index', ], window: { backgroundTextStyle: 'light', - navigationBarBackgroundColor: '#fff', - navigationBarTitleText: 'WeChat', - navigationBarTextStyle: 'black' - } + navigationBarBackgroundColor: '#2F4FEE', + navigationBarTitleText: '网上快办平台', + navigationBarTextStyle: 'white' + }, + permission: { + 'scope.userLocation': { + desc: '用于选择门店地图位置' + } + }, + requiredPrivateInfos: [ + 'getLocation', + 'chooseLocation', + ], }) diff --git a/src/assets/images/landing-storefronts.jpg b/src/assets/images/landing-storefronts.jpg deleted file mode 100644 index 33c7129..0000000 Binary files a/src/assets/images/landing-storefronts.jpg and /dev/null differ diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts new file mode 100644 index 0000000..57de3e3 --- /dev/null +++ b/src/lib/auth.test.ts @@ -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('获取临时登录态失败') + }) +}) diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..0361dfc --- /dev/null +++ b/src/lib/auth.ts @@ -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 +}) { + const existing = String(input.token || '').trim() + if (existing) { + return existing + } + + const token = pickToken(await input.fetchUserConfig()) + if (!token) { + throw new Error('获取临时登录态失败') + } + + return token +} diff --git a/src/lib/bootstrap.ts b/src/lib/bootstrap.ts new file mode 100644 index 0000000..a0e3d49 --- /dev/null +++ b/src/lib/bootstrap.ts @@ -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 = {}) { + 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() +} diff --git a/src/lib/cdn.ts b/src/lib/cdn.ts new file mode 100644 index 0000000..b505865 --- /dev/null +++ b/src/lib/cdn.ts @@ -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: '门店定位', +} diff --git a/src/lib/contact.test.ts b/src/lib/contact.test.ts new file mode 100644 index 0000000..a4bcac8 --- /dev/null +++ b/src/lib/contact.test.ts @@ -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) + }) +}) diff --git a/src/lib/contact.ts b/src/lib/contact.ts new file mode 100644 index 0000000..6aa2863 --- /dev/null +++ b/src/lib/contact.ts @@ -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 | 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 | null) { + const contact = pickServiceContact(config) + return Boolean(contact.online || contact.phone || contact.weixin) +} + +export function mergeServiceConfig (current: Record | 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 +} diff --git a/src/lib/crypto.test.ts b/src/lib/crypto.test.ts new file mode 100644 index 0000000..658a1ae --- /dev/null +++ b/src/lib/crypto.test.ts @@ -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) + }) +}) diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..08440ec --- /dev/null +++ b/src/lib/crypto.ts @@ -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) +} diff --git a/src/lib/geocode.test.ts b/src/lib/geocode.test.ts new file mode 100644 index 0000000..ea41e51 --- /dev/null +++ b/src/lib/geocode.test.ts @@ -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) + }) +}) diff --git a/src/lib/geocode.ts b/src/lib/geocode.ts new file mode 100644 index 0000000..67eb4a8 --- /dev/null +++ b/src/lib/geocode.ts @@ -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 | null { + return value && typeof value === 'object' ? value as Record : 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 } +} diff --git a/src/lib/launch.test.ts b/src/lib/launch.test.ts new file mode 100644 index 0000000..e4bfb5b --- /dev/null +++ b/src/lib/launch.test.ts @@ -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', + }) + }) +}) + diff --git a/src/lib/launch.ts b/src/lib/launch.ts new file mode 100644 index 0000000..921cc2c --- /dev/null +++ b/src/lib/launch.ts @@ -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 = {}): 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 = {}): 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, + } +} diff --git a/src/lib/order.test.ts b/src/lib/order.test.ts new file mode 100644 index 0000000..fb4cdd8 --- /dev/null +++ b/src/lib/order.test.ts @@ -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', + }, + }) + }) +}) diff --git a/src/lib/order.ts b/src/lib/order.ts new file mode 100644 index 0000000..c7006bd --- /dev/null +++ b/src/lib/order.ts @@ -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 + extra?: Record + [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 (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>((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), + }, + } +} diff --git a/src/lib/origin.test.ts b/src/lib/origin.test.ts new file mode 100644 index 0000000..c9cc73a --- /dev/null +++ b/src/lib/origin.test.ts @@ -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') + }) +}) + diff --git a/src/lib/origin.ts b/src/lib/origin.ts new file mode 100644 index 0000000..61c6e90 --- /dev/null +++ b/src/lib/origin.ts @@ -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) +} diff --git a/src/lib/phone.test.ts b/src/lib/phone.test.ts new file mode 100644 index 0000000..715959e --- /dev/null +++ b/src/lib/phone.test.ts @@ -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) + }) +}) diff --git a/src/lib/phone.ts b/src/lib/phone.ts new file mode 100644 index 0000000..d9571c1 --- /dev/null +++ b/src/lib/phone.ts @@ -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()) +} diff --git a/src/lib/qiniu.test.ts b/src/lib/qiniu.test.ts new file mode 100644 index 0000000..13d2f4c --- /dev/null +++ b/src/lib/qiniu.test.ts @@ -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' + ) + }) +}) diff --git a/src/lib/qiniu.ts b/src/lib/qiniu.ts new file mode 100644 index 0000000..5e72a44 --- /dev/null +++ b/src/lib/qiniu.ts @@ -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' +} diff --git a/src/lib/request.ts b/src/lib/request.ts new file mode 100644 index 0000000..70fd1ae --- /dev/null +++ b/src/lib/request.ts @@ -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 + data?: any + silent?: boolean +} + +export type BusinessResponse = { + 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) { + return Object.keys(params) + .map((key) => `${key}=${encodeURIComponent(params[key] ?? '')}`) + .join('&') +} + +function buildHeaders () { + const session = getSession() + const headers: Record = { + '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 (options: RequestOptions): Promise> { + 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 + 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() + } +} diff --git a/src/lib/reverseGeocode.ts b/src/lib/reverseGeocode.ts new file mode 100644 index 0000000..59d3925 --- /dev/null +++ b/src/lib/reverseGeocode.ts @@ -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 { + 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) +} \ No newline at end of file diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..836ccad --- /dev/null +++ b/src/lib/session.ts @@ -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 +} + +const EMPTY_SESSION: SessionState = { + token: '', + host: '', + packageId: '', + scene: '', + linkId: '', + shareId: '', + prevId: '', + phone: '', + deviceId: '', + config: {}, +} + +function readStorage (): Partial { + 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) { + memorySession = { + ...getSession(), + ...patch, + } + writeStorage(memorySession) + return memorySession +} + +export function applyLaunchParams (raw: Record = {}) { + 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 = { + config: mergeServiceConfig(getSession().config, payload), + } + if (token) patch.token = token + if (packageId) patch.packageId = packageId + return setSession(patch) +} diff --git a/src/lib/sign.test.ts b/src/lib/sign.test.ts new file mode 100644 index 0000000..f2ad392 --- /dev/null +++ b/src/lib/sign.test.ts @@ -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) + }) +}) diff --git a/src/lib/sign.ts b/src/lib/sign.ts new file mode 100644 index 0000000..e9fbe61 --- /dev/null +++ b/src/lib/sign.ts @@ -0,0 +1,31 @@ +import MD5 from 'crypto-js/md5' + +export const SIGN_KEY = 'MsvqoWCMxJsozicF5K4EFVSoVf8rEHfn' + +type SignInput = { + method?: string + params?: Record + 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, + } +} diff --git a/src/lib/status.ts b/src/lib/status.ts new file mode 100644 index 0000000..46d42bb --- /dev/null +++ b/src/lib/status.ts @@ -0,0 +1,23 @@ +export const STATUS_CONFIG: Record = { + '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 = { + 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, + } +} diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts new file mode 100644 index 0000000..5f01178 --- /dev/null +++ b/src/lib/uuid.ts @@ -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) + }) +} diff --git a/src/pages/index/contact.tsx b/src/pages/index/contact.tsx new file mode 100644 index 0000000..b3939cf --- /dev/null +++ b/src/pages/index/contact.tsx @@ -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 ( + + + + {title} + {desc} + + + ) +} + +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 ( + + event.stopPropagation()}> + 联系客服 + 工作时间段:法定工作日 9:00-18:00 + + {contact.online ? ( + void copyLink(contact.online, '在线客服')} + /> + ) : null} + + {contact.weixin ? ( + void copyLink(contact.weixin, '微信客服')} + /> + ) : null} + + {contact.phone ? ( + + ) : null} + + {!hasAny ? ( + 暂无客服信息,请稍后再试 + ) : null} + + + 关闭 + + + + ) +} + +export function ContactEntry ({ onClick }: { onClick: () => void }) { + return ( + + + 联系我们 + + ) +} diff --git a/src/pages/index/index.config.ts b/src/pages/index/index.config.ts index 12abc5f..282742d 100644 --- a/src/pages/index/index.config.ts +++ b/src/pages/index/index.config.ts @@ -1,3 +1,5 @@ export default definePageConfig({ - navigationBarTitleText: '首页' + navigationBarTitleText: '网上快办平台', + navigationBarBackgroundColor: '#2F4FEE', + navigationBarTextStyle: 'white', }) diff --git a/src/pages/index/index.scss b/src/pages/index/index.scss index e69de29..f886be2 100644 --- a/src/pages/index/index.scss +++ b/src/pages/index/index.scss @@ -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; +} diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index 3f2881f..134d8db 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -1,97 +1,283 @@ -import { View, WebView } from '@tarojs/components' -import Taro, { useLoad } from '@tarojs/taro' +import { Button, Image, Input, Text, View } from '@tarojs/components' +import Taro, { useDidShow, useLoad } from '@tarojs/taro' 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' -// xunmeng2,devcon -const LINK_PREFIX = '/p/' -const LANDING_PAGE_URL = '/pages/landing/index' -const SCENE_STORAGE_KEY = 'qrcodeScene' +const COPY_ICON = `${CORP_ASSET_BASE}/images-mobile/icon-copy.png` -function isValidLinkCode (code: string) { - return Boolean(code) && !/[/?#&=]/.test(code) -} +export default function Index () { + const [ready, setReady] = useState(false) + const [inputPhone, setInputPhone] = useState('') + const [checking, setChecking] = useState(false) + const [searched, setSearched] = useState(false) + const [orders, setOrders] = useState([]) + const [detailOrder, setDetailOrder] = useState(null) + const [resubmitOrder, setResubmitOrder] = useState(null) + const [mapResult, setMapResult] = useState(null) + const [showContact, setShowContact] = useState(false) + const [contact, setContact] = useState({ online: '', phone: '', weixin: '' }) -function normalizeQrLinkCode (scene?: string) { - if (!scene) return '' - - try { - const decodedScene = decodeURIComponent(scene) - const linkParam = new URLSearchParams(decodedScene).get('link') - const rawCode = (linkParam || decodedScene).trim() - const code = rawCode.startsWith(LINK_PREFIX) - ? rawCode.slice(LINK_PREFIX.length) - : rawCode - - return isValidLinkCode(code) ? code : '' - } catch (error) { - console.warn('Invalid qrcode scene.', error) - return '' - } -} - -function buildWebViewUrl (linkCode: string, mpCode: string, options: Record) { - const queryParams = new URLSearchParams() - - Object.entries(options).forEach(([key, value]) => { - if (typeof value === 'string') { - queryParams.set(key, value) + useLoad(async (options) => { + try { + const session = await bootstrapSession(options || {}) + setInputPhone(session.phone) + setContact(pickServiceContact(session.config)) + setReady(true) + } catch (error) { + Taro.showToast({ + title: error instanceof Error ? error.message : '初始化失败', + icon: 'none', + }) } }) - queryParams.set('mpCode', mpCode) - - return `https://nb.zuom8.cn${LINK_PREFIX}${encodeURIComponent(linkCode)}?${queryParams.toString()}` -} - -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) - } + useDidShow(() => { + const result = consumeMapPickerResult() + if (result) { + setMapResult(result) } + }) - const linkCode = normalizeQrLinkCode(scene) - - if (!linkCode) { - try { - await Taro.redirectTo({ url: LANDING_PAGE_URL }) - } catch (error) { - console.error('Failed to open landing page.', error) - Taro.showToast({ - title: '页面加载失败', - icon: 'none' - }) - } - + const queryOrders = async (phone: string) => { + if (!phone) { + Taro.showToast({ title: '请输入联系电话', icon: 'none' }) + return + } + if (!isValidPhone(phone)) { + Taro.showToast({ title: '请填写正确联系电话', icon: 'none' }) 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 ( - - { - url && - } + + + + {PAGE_TITLE} + + setShowContact(true)} /> + + + + + 进度查询 + + + + 注:无法查询订单请联系客服 + + + 联系电话 + * + + setInputPhone(event.detail.value.replace(/\D/g, ''))} + /> + + + + + + {searched ? ( + + + + 进度查询 + + + + {orders.length ? orders.map((order, index) => ( + { + if (!order.out_trade_no) return + Taro.setClipboardData({ data: String(order.out_trade_no) }) + }} + onViewDetail={() => setDetailOrder(order)} + onResubmit={() => { + setMapResult(null) + setResubmitOrder(order) + }} + /> + )) : ( + + 暂无记录 + + )} + + + + ) : null} + + {detailOrder ? ( + setDetailOrder(null)}> + event.stopPropagation()}> + 订单详情 + + + + + + + + + ) : null} + + setShowContact(false)} + /> + + { + setResubmitOrder(null) + setMapResult(null) + }} + onChooseMap={handleChooseMap} + onSuccess={() => { + setResubmitOrder(null) + setMapResult(null) + void queryOrders(inputPhone) + }} + /> + + ) +} + +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 ( + + + + {goodsIcons.map((icon: string, index: number) => ( + + ))} + {order.goods_name || '-'} + + + {status.result} + + + + {order.reject_reason ? ( + 驳回理由:{order.reject_reason} + ) : null} + + + + + + + + + 订单ID: + {order.out_trade_no || '-'} + {order.out_trade_no ? ( + + ) : null} + + + + 支付方式: + {payIcon ? : null} + ¥{order.total_fee || '-'} + + + + {showDetail ? ( + 查看详情 + ) : null} + 补充资料 + + + ) +} + +function InfoLine ({ label, value }: { label: string, value: string }) { + return ( + + {label} + {value} ) } diff --git a/src/pages/index/resubmit.tsx b/src/pages/index/resubmit.tsx new file mode 100644 index 0000000..f22affe --- /dev/null +++ b/src/pages/index/resubmit.tsx @@ -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 ( + + + {text} + {required ? * : null} + + ) +} + +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 ( + + {values.map((value, index) => ( + + + onRemove(index)}> + × + + + ))} + {showUpload ? ( + void handleChoose()}> + + + {uploading ? `上传中 ${Math.round(progress)}%` : (multiple && values.length > 0 ? '继续上传' : `请上传${label}`)} + + + ) : null} + + ) +} + +export default function ResubmitDrawer ({ + open, + order, + mapResult, + onCancel, + onChooseMap, + onSuccess, +}: Props) { + const [values, setValues] = useState({}) + const [storefrontImages, setStorefrontImages] = useState([]) + const [licenseImages, setLicenseImages] = useState([]) + 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 = (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 ( + + + + × + + 重新提交 + {order.reject_reason ? ( + 驳回理由:{order.reject_reason} + ) : null} + + + + + updateField('entity_name', event.detail.value)} + /> + + + + + updateField('entity_address_name', event.detail.value)} + /> + + + + + onChooseMap({ + ...values, + entity_address: mapPoint ? formatMapCoordinate(mapPoint) : values.entity_address, + })} + > + + {values.entity_address || '请选择地图位置'} + + + + + + + + updateField('entity_phone', event.detail.value.replace(/\D/g, ''))} + /> + + + + + updateField('entity_phone2', event.detail.value.replace(/\D/g, ''))} + /> + + + 温馨提示:请确保您的电话畅通,工作人员会在1-7个工作日联系您 + + + + void handleUpload('storefront', paths)} + onRemove={(index) => setStorefrontImages((current) => current.filter((_, itemIndex) => itemIndex !== index))} + /> + + + + + void handleUpload('license', paths)} + onRemove={() => setLicenseImages([])} + /> + + + {formError ? {formError} : null} + + + + + + ) +} diff --git a/src/pages/landing/index.scss b/src/pages/landing/index.scss deleted file mode 100644 index 23d6314..0000000 --- a/src/pages/landing/index.scss +++ /dev/null @@ -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; - } -} diff --git a/src/pages/landing/index.tsx b/src/pages/landing/index.tsx deleted file mode 100644 index a321748..0000000 --- a/src/pages/landing/index.tsx +++ /dev/null @@ -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> - -type DocumentSection = { - heading: string - content: string -} - -const CONTACT_PHONE = '4001166311' - -const INITIAL_FORM: ConsultationForm = { - consultationType: 'operation', - businessName: '', - businessCategory: '', - contactName: '', - contactPhone: '', - description: '', -} - -const DOCUMENTS: Record = { - 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(INITIAL_FORM) - const [agreed, setAgreed] = useState(false) - const [errors, setErrors] = useState({}) - const [activeDocument, setActiveDocument] = useState(null) - - const updateField = (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 ( - - - 门店经营咨询,在线填写 - - 提供经营信息梳理、品牌展示建议和宣传资料制作咨询,帮助线下门店明确实际需求。 - - 企业咨询服务,不提供任何平台官方代办服务 - - - - - 咨询类型 - updateField('consultationType', event.detail.value as ConsultationType)} - > - - 经营信息咨询 - - - 品牌展示咨询 - - - - - - 企业或门店名称 - updateField('businessName', event.detail.value)} - /> - {errors.businessName && {errors.businessName}} - - - - 经营类别 - updateField('businessCategory', event.detail.value)} - /> - {errors.businessCategory && {errors.businessCategory}} - - - - 联系人 - updateField('contactName', event.detail.value)} - /> - {errors.contactName && {errors.contactName}} - - - - 联系电话 - updateField('contactPhone', event.detail.value)} - /> - {errors.contactPhone && {errors.contactPhone}} - - - - 咨询需求 -