ud: replace a4-index with a5

This commit is contained in:
zhangjianjun 2026-09-08 14:36:22 +08:00
parent 99c188a26b
commit d43072372e
67 changed files with 389 additions and 3645 deletions

View File

@ -1,4 +1,2 @@
TARO_APP_API_ORIGIN=https://corp-test.batiao8.com
TARO_APP_API_A5=https://ag-test.batiao8.com TARO_APP_API_A5=https://ag-test.batiao8.com
TARO_APP_API_HOST=nb.batiao8.com
TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198 TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198

View File

@ -1,4 +1,2 @@
TARO_APP_API_ORIGIN=https://corp.batiao8.com
TARO_APP_API_A5=https://ag.batiao8.com TARO_APP_API_A5=https://ag.batiao8.com
TARO_APP_API_HOST=nb.zuom8.cn
TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198 TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198

View File

@ -1 +0,0 @@
# TARO_APP_ID="测试环境下的小程序 AppID"

View File

@ -19,24 +19,27 @@ Other targets follow `pnpm dev:<platform>` / `pnpm build:<platform>` (`alipay`,
**Running the mini-program:** builds output to `./dist`. Open `./dist` in WeChat DevTools (`project.config.json` already points `miniprogramRoot` there; appId `wxf8a8961f30396c19`). **Running the mini-program:** builds output to `./dist`. Open `./dist` in WeChat DevTools (`project.config.json` already points `miniprogramRoot` there; appId `wxf8a8961f30396c19`).
**There is no test runner and no lint script in `package.json`.** ESLint (`taro/react`) and Stylelint are configured but run manually. The only enforced gate is a Husky `commit-msg` hook running **commitlint** (Conventional Commits) — non-conforming commit messages are rejected. **Tests:** `pnpm test` runs Vitest. There is no lint script; ESLint (`taro/react`) and Stylelint run manually. The Husky `commit-msg` hook runs **commitlint** (Conventional Commits).
## Architecture ## Architecture
Native mini-program for map-label progress query. Pages in `src/app.config.ts`: Native A5 mini-program for map-label progress query. Pages in `src/app.config.ts`:
**`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. - `pages/index/index`: A5 progress query, order details and customer service. Accepts `phone`, `link_id` / `linkId`, or `/h/<id>` in `scene`.
- `pages/resubmit/index`: A5 business information and document uploads.
- `pages/complaint/index`: A5 order complaints.
- `pages/map/index`: native map selection returning GCJ-02 `lng,lat`.
- `pages/agreement/index`: service and privacy agreements.
**`pages/map/index` — 微信原生地图选点.** Tap / locate / `chooseLocation` search, returns GCJ-02 `lng,lat`. ### Request and configuration
### Session and request - A5 client and draft storage: `src/lib/a5/api.ts`; data adapters: `src/lib/a5/data.ts`.
- `TARO_APP_API_A5`: development `https://ag-test.batiao8.com`, production `https://ag.batiao8.com`.
- API origin comes from `TARO_APP_API_ORIGIN`: `.env.development``http://corp-test.batiao8.com`, `.env.production``https://nb.zuom8.cn`. - `TARO_APP_TIANDITU_KEY`: reverse geocoding against `api.tianditu.gov.cn`.
- `x-host` prefers the H5 jump `host`, then `TARO_APP_API_HOST`. - Orders use `GET/PUT /h5/order`, configuration uses `GET /h5/link`, files use `/h5/file`.
- Map reverse geocode uses `TARO_APP_TIANDITU_KEY` against `api.tianditu.gov.cn` (add this request domain in WeChat admin). - No corp token bootstrap, x-host, default launch scheme, signing/decryption or presigned Qiniu upload.
- Missing launch query fields fall back to `DEFAULT_LAUNCH_SCHEME` in `src/lib/launch.ts` (`scene=/h/KZ9Q`, `host=nb.batiao8.com`, `package=10044`). - Only A5 business behavior is supported. The former `pages/a5/*` routes are removed; external entries must use `pages/index/index`.
- No token: `GET /api/user/config` then keep the returned guest token. Then `GET /api/h5/corp` for package/config. - See `docs/a5-integration.md` for API details, legal domains and the existing complaint HTTP 404 fallback.
- 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. Do not reintroduce a business H5 WebView unless explicitly requested.
@ -48,5 +51,3 @@ Do not reintroduce a business H5 WebView unless explicitly requested.
- **Styling:** SCSS with BEM (`block__element--modifier`); CSS Modules are disabled. Design width is `750`, so author dimensions in `px` — Taro converts to `rpx`/`rem` at compile time. - **Styling:** SCSS with BEM (`block__element--modifier`); CSS Modules are disabled. Design width is `750`, so author dimensions in `px` — Taro converts to `rpx`/`rem` at compile time.
- **Imports:** `@/*` is aliased to `src/*` (see `tsconfig.json`). - **Imports:** `@/*` is aliased to `src/*` (see `tsconfig.json`).
- `strictNullChecks`, `noUnusedLocals`, and `noUnusedParameters` are on; `noImplicitAny` is off. - `strictNullChecks`, `noUnusedLocals`, and `noUnusedParameters` are on; `noImplicitAny` is off.
A more verbose Chinese-language version of these notes lives in `GEMINI.md`.

182
DESIGN.md
View File

@ -1,182 +0,0 @@
---
name: 门店管理服务
description: 面向线下门店经营负责人的蓝色科技感业务落地页
colors:
midnight-navy: "#05256E"
service-blue: "#0B57D0"
action-blue: "#1677FF"
signal-cyan: "#5FC8FF"
canvas-blue: "#F3F7FF"
pure-white: "#FFFFFF"
trust-ink: "#102A56"
supporting-ink: "#425F8C"
soft-border: "#D7E4FF"
typography:
display:
fontFamily: "-apple-system, BlinkMacSystemFont, PingFang SC, Microsoft YaHei, sans-serif"
fontSize: "56px"
fontWeight: 700
lineHeight: 1.18
letterSpacing: "-1px"
title:
fontFamily: "-apple-system, BlinkMacSystemFont, PingFang SC, Microsoft YaHei, sans-serif"
fontSize: "32px"
fontWeight: 700
lineHeight: 1.4
body:
fontFamily: "-apple-system, BlinkMacSystemFont, PingFang SC, Microsoft YaHei, sans-serif"
fontSize: "28px"
fontWeight: 400
lineHeight: 1.65
label:
fontFamily: "-apple-system, BlinkMacSystemFont, PingFang SC, Microsoft YaHei, sans-serif"
fontSize: "24px"
fontWeight: 600
lineHeight: 1.4
rounded:
sm: "12px"
md: "24px"
lg: "36px"
pill: "999px"
spacing:
xs: "8px"
sm: "16px"
md: "24px"
lg: "32px"
xl: "48px"
section: "72px"
components:
call-button:
backgroundColor: "{colors.action-blue}"
textColor: "{colors.pure-white}"
rounded: "{rounded.pill}"
height: "88px"
padding: "0 36px"
industry-mark:
backgroundColor: "{colors.canvas-blue}"
textColor: "{colors.service-blue}"
rounded: "{rounded.md}"
size: "112px"
service-item:
backgroundColor: "{colors.pure-white}"
textColor: "{colors.trust-ink}"
rounded: "{rounded.md}"
padding: "28px"
---
# Design System: 门店管理服务
## Overview
**Creative North Star: "清晨开店前的蓝色灯箱"**
页面像门店营业前刚亮起的蓝色灯箱:清晰、稳定、有秩序。深蓝主视觉承担品牌识别,纯白内容面保证业务说明快速可读,青蓝只用于光线和细节提示。
整个系统拒绝地图工具、表单后台和政务门户的视觉语言,也拒绝用大量发光、玻璃面板或彩虹图标堆叠科技感。布局应让经营负责人在短时间内认出自己的行业、理解服务内容并完成电话咨询。
**Key Characteristics:**
- 深蓝主视觉与浅色信息面的明确分区,不用面板覆盖主视觉。
- 门店剪影完整呈现,真实业务文案始终由原生组件渲染。
- 行业标识收纳在统一横向信息带中,服务说明舒展,唯一主操作固定在底部。
- 轻量入场动效有静态降级,不阻塞内容显示。
## Colors
以深蓝建立信任,以亮蓝承载行动,以青蓝提供有限的光感;内容区保持接近纯白,正文使用带蓝相的深色文字。
### Primary
- **午夜门楣蓝:** 用于原生导航栏、首屏深色背景与最深层光影。
- **服务蓝:** 用于主视觉过渡、行业标识和服务序号。
- **行动蓝:** 仅用于拨号按钮和关键交互状态。
### Secondary
- **信号青:** 只用于插画轮廓、光带和小面积高光,不承载正文。
### Neutral
- **清晨画布:** 页面内容背景,带极轻蓝相。
- **纯白:** 服务内容与固定电话栏的主要表面。
- **信任墨蓝:** 标题和正文主色。
- **辅助墨蓝:** 次要说明文字,仍需满足 AA 对比度。
- **柔和分隔蓝:** 仅用于完整边框和分隔线。
**The Blue Commitment Rule.** 蓝色覆盖首屏主视觉,但内容区必须回到高对比浅色表面;禁止用第三种高饱和色争夺注意力。
## Typography
**Display Font:** 系统无衬线字体(优先 PingFang SC
**Body Font:** 系统无衬线字体(优先 PingFang SC
**Character:** 单一系统字体通过字号和字重建立层级,加载稳定,适合微信小程序和中文快速扫读。
### Hierarchy
- **Display**70056px1.18):仅用于首屏“门店管理服务”。
- **Title**70032px1.4):用于“服务行业”“核心服务”和服务名称。
- **Body**40028px1.65):用于业务说明,单行长度受移动端容器约束。
- **Label**60024px1.4):用于行业名称、电话提示和辅助标签。
**The Plain Language Rule.** 标题直接命名业务,按钮使用“立即拨打”等动词加对象表达;禁止口号式套话和全大写正文。
## Elevation
系统以色块、完整边框和有限的环境阴影建立层次。内容表面默认平坦,固定电话栏可使用向上的柔和阴影与页面分离,主视觉光晕只存在于蓝色背景内部。
### Shadow Vocabulary
- **内容环境影:** 低透明度、大模糊半径,只用于主内容容器与蓝色主视觉交界处。
- **固定栏上浮影:** 从底部栏向上扩散,说明其固定层级,不模拟厚重卡片。
**The Flat Content Rule.** 服务信息依靠留白与分隔,不给每个条目添加独立厚重阴影。
## Components
### Buttons
- **Shape:** 44px 视觉高度的胶囊按钮88px 设计稿高度)。
- **Primary:** 行动蓝底、纯白粗体文字,左右内边距 36px。
- **Active / Focus:** 按下时轻微降低亮度;焦点通过完整轮廓表达,不移动布局。
### Chips
- **Style:** 三个行业标识共用一个横向信息带,以 1px 分隔线划分;每项由浅蓝圆形字符标识和行业名称组成。
- **State:** 全部为静态信息,不提供选中、悬停或点击状态。
### Cards / Containers
- **Corner Style:** 主内容容器 36px服务条目 24px。
- **Background:** 纯白或清晨画布。
- **Shadow Strategy:** 主容器允许环境影,服务条目使用完整边框或分隔线。
- **Internal Padding:** 24px 至 40px保持移动端舒适密度。
### Navigation
- 原生导航栏使用午夜门楣蓝背景、纯白标题“门店管理服务”,不放 Logo 或自定义返回控件。
### Fixed Consultation Bar
- 白色固定底栏包含“业务咨询”、电话号码和“立即拨打”按钮。
- 底部使用安全区内边距,页面正文预留等高空间,任何屏幕都不得遮挡内容。
## Do's and Don'ts
### Do:
- **Do** 让百货、超市、便利店在首屏直接可见。
- **Do** 使用原生文字承载业务内容,插画加载失败时信息仍完整。
- **Do** 按图片宽度展示门店主视觉,只裁去上方空蓝区域,完整保留门店建筑与地面基线。
- **Do** 保持正文至少 4.5:1 对比度、按钮约 44px 触控高度。
- **Do** 为所有入场动画提供 `prefers-reduced-motion` 静态降级。
- **Do** 使用完整边框、留白和色块建立层级。
### Don't:
- **Don't** 做地图、标注、定位、坐标或地址管理界面。
- **Don't** 做表单页、政务门户或业务办理后台的视觉复刻。
- **Don't** 使用 Logo、彩虹分类图标、夸张赛博霓虹或装饰性玻璃拟态。
- **Don't** 把行业与服务包装成可点击但没有目标的假入口。
- **Don't** 使用负 margin 内容面板覆盖门店图片,或用深色渐变压暗门店底部。
- **Don't** 使用渐变文字、侧边粗色条、嵌套卡片或重复的图标卡片网格。

View File

@ -1,36 +0,0 @@
# Product
## Register
brand
## Users
面向百货、超市与便利店的经营负责人和运营负责人。他们通常在移动端快速了解服务范围,并希望用最短路径联系业务人员。
## Product Purpose
落地页用于清楚介绍门店管理服务覆盖的行业与能力,并将没有 `scene` 的自然访问流量转化为电话咨询。成功标准是访问者能在首屏理解业务对象,在一次滚动内看到完整服务范围,并随时拨打咨询电话。
## Brand Personality
可靠、利落、专业。文案直接说明业务,不夸大结果,不使用营销套话;视觉有明确科技感,但不牺牲可读性和可信度。
## Anti-references
- 不做地图、标注、定位、坐标或地址管理界面。
- 不做表单页、政务门户或业务办理后台的视觉复刻。
- 不使用 Logo、彩虹分类图标、夸张赛博霓虹或装饰性玻璃拟态。
- 不把行业与服务包装成可点击但没有目标的假入口。
## Design Principles
- **先讲清服务对象。** 首屏必须直接出现百货、超市与便利店。
- **唯一行动清晰。** 电话咨询是页面唯一主操作,并始终可见。
- **科技感服务于信任。** 蓝色、光线和门店插画建立专业氛围,不制造地图或数据监控联想。
- **信息先于装饰。** 即使插画未加载,标题、服务范围和联系电话仍完整可用。
- **移动端快速扫读。** 使用短文案、强层级和足够触控尺寸。
## Accessibility & Inclusion
以移动端 WCAG AA 为基线:正文对比度至少 4.5:1大字号至少 3:1交互区域视觉尺寸不小于约 44px不仅依赖颜色传达信息并为减少动态效果提供静态降级。

View File

@ -1,33 +0,0 @@
# Design QA
- source visual truth path: `C:\Users\29169\AppData\Local\Temp\codex-clipboard-5202e314-49da-48b8-a154-05e5d4a96bcc.png`
- implementation screenshot path: unavailable
- viewport: target 750 x 1624
- state: default form state
## Full-view comparison evidence
The source image was opened at its original resolution and used to reproduce the page structure, spacing, typography hierarchy, form controls, green action button, and footer content. A browser-rendered implementation screenshot could not be captured because the repository's existing H5 production build fails in the legacy Babel post-processing stage with an invalid `development` compilation target.
## Focused region comparison evidence
Blocked for the same reason. Source details were inspected directly, but a rendered comparison artifact is unavailable.
## Findings
- No code-level P0/P1 issue was found by the WeChat production build or stylesheet validation.
- Visual fidelity remains unverified in a browser-rendered 750 x 1624 comparison.
## Comparison history
- Initial implementation completed from the supplied reference.
- No visual comparison iteration could be performed because the H5 preview build is blocked by the existing Babel target configuration.
## Verification
- Primary interactions implemented: service type selection, all text inputs, agreement selection, policy dialogs, validation feedback, and success dialog.
- WeChat production build: passed.
- Stylesheet validation: passed.
- Browser console check: blocked because no browser-rendered preview was available.
final result: blocked

View File

@ -2,20 +2,21 @@
## Launch Contract ## Launch Contract
`pages/a5/index?phone=13800138000&link_id=42` `pages/index/index?phone=13800138000&link_id=42`
- `phone`: auto-query phone; manual query remains available without it. - `phone`: auto-query phone; manual query remains available without it.
- `link_id`: A5 link ID for configuration, business documents and completion files. - `link_id`: A5 link ID for configuration, business documents and completion files. Defaults to `2x6` when neither `link_id` / `linkId` nor a valid `/h/<id>` scene supplies a link ID.
- Legacy `/h/<id>` scenes and URL Link encoded separators are accepted. - Legacy `/h/<id>` scenes and URL Link encoded separators are accepted.
- No corp token, host, package or default short link is used by A5. - No corp token, host, package or default short link is used by A5.
- agent-h5 resolves the link from `agentLink.linkId`, `linkId`, then `/h/<id>`. - agent-h5 resolves the link from `agentLink.linkId`, `linkId`, then `/h/<id>`.
## Environments ## Environments
- Development: `TARO_APP_API_A5=http://ag-test.batiao8.com` - Development: `TARO_APP_API_A5=https://ag-test.batiao8.com`
- Production: `TARO_APP_API_A5=http://ag.batiao8.com` - Production: `TARO_APP_API_A5=https://ag.batiao8.com`
- Map reverse geocoding: `TARO_APP_TIANDITU_KEY`.
HTTP is configured as requested for regular requests. File uploads always use HTTPS on the same A5 host because WeChat uploadFile requires it. Before a WeChat release, verify the HTTPS gateway and certificate and update the environment value for all requests. Configure request/upload/download legal domains, including returned file CDN domains. HTTP is not a release-ready WeChat endpoint. Requests and uploads use HTTPS. Configure request/upload/download legal domains, including returned file CDN domains and `api.tianditu.gov.cn`. The former corp API origin and x-host environment variables have been removed.
## API Contract ## API Contract
@ -42,6 +43,8 @@ WeChat uses `chooseMedia` with `mediaType: ['image']`; other targets retain `cho
- In agent-h5: `pnpm exec vitest run src/utils/weixinMiniProgram.test.ts src/ui/map_label/utils/miniProgramEntry.test.ts` - In agent-h5: `pnpm exec vitest run src/utils/weixinMiniProgram.test.ts src/ui/map_label/utils/miniProgramEntry.test.ts`
- In agent-h5: `pnpm build` - In agent-h5: `pnpm build`
Open the mini-program project in WeChat DevTools, using `dist` as the mini-program root. Set the compile page to `pages/a5/index` and use an authorized test phone and matching A5 link ID. Check auto-query, pending-submission status, three required storefront slots, optional license/identity slots, example previews, map return, deletion, resubmission refresh, platform screenshots, PDF opening and complaints. Revisit the original index to verify it still uses the corp service. Open the mini-program project in WeChat DevTools, using `dist` as the mini-program root. Set the compile page to `pages/index/index` and use an authorized test phone and matching A5 link ID. Check auto-query, pending-submission status, three required storefront slots, optional license/identity slots, example previews, map return, deletion, resubmission refresh, platform screenshots, PDF opening and complaints.
A5 is the only business flow. Resubmission and complaints use `pages/resubmit/index` and `pages/complaint/index`; map selection and agreements remain shared pages. The former `pages/a5/*` routes have been removed, so external entry links must use `pages/index/index`.
No real customer orders were submitted or modified during implementation verification. No real customer orders were submitted or modified during implementation verification.

View File

@ -61,8 +61,6 @@
"@tarojs/runtime": "4.2.0", "@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0", "@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0", "@tarojs/taro": "4.2.0",
"crypto-js": "^4.2.0",
"qrcode": "^1.5.4",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0" "react-dom": "^18.0.0"
}, },
@ -75,9 +73,7 @@
"@tarojs/cli": "4.2.0", "@tarojs/cli": "4.2.0",
"@tarojs/plugin-generator": "4.2.0", "@tarojs/plugin-generator": "4.2.0",
"@tarojs/vite-runner": "4.2.0", "@tarojs/vite-runner": "4.2.0",
"@types/crypto-js": "^4.2.2",
"@types/minimatch": "^5", "@types/minimatch": "^5",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.0.0", "@types/react": "^18.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"babel-preset-taro": "4.2.0", "babel-preset-taro": "4.2.0",

View File

@ -56,12 +56,6 @@ importers:
'@tarojs/taro': '@tarojs/taro':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0) version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)
crypto-js:
specifier: ^4.2.0
version: 4.2.0
qrcode:
specifier: ^1.5.4
version: 1.5.4
react: react:
specifier: ^18.0.0 specifier: ^18.0.0
version: 18.3.1 version: 18.3.1
@ -93,15 +87,9 @@ importers:
'@tarojs/vite-runner': '@tarojs/vite-runner':
specifier: 4.2.0 specifier: 4.2.0
version: 4.2.0(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(jiti@2.6.1)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@4.5.14(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0)) version: 4.2.0(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(jiti@2.6.1)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@4.5.14(@types/node@25.9.2)(sass@1.100.0)(terser@5.48.0))
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
'@types/minimatch': '@types/minimatch':
specifier: ^5 specifier: ^5
version: 5.1.2 version: 5.1.2
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react': '@types/react':
specifier: ^18.0.0 specifier: ^18.0.0
version: 18.3.31 version: 18.3.31
@ -2087,9 +2075,6 @@ packages:
'@types/conventional-commits-parser@5.0.2': '@types/conventional-commits-parser@5.0.2':
resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==} resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/debug@4.1.13': '@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@ -2132,9 +2117,6 @@ packages:
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react@18.3.31': '@types/react@18.3.31':
resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
@ -2561,10 +2543,6 @@ packages:
camel-case@4.1.2: camel-case@4.1.2:
resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001797: caniuse-lite@1.0.30001797:
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
@ -2641,9 +2619,6 @@ packages:
resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@7.0.4: cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@ -2765,10 +2740,6 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
css-functions-list@3.3.3: css-functions-list@3.3.3:
resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -2818,10 +2789,6 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-uri-component@0.2.2: decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
@ -2895,9 +2862,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dingtalk-jsapi@2.15.6: dingtalk-jsapi@2.15.6:
resolution: {integrity: sha512-804mFz2AFV/H9ysmo7dLqMjSGOQgREsgQIuep+Xg+yNQeQtnUOYntElEzlB798Sj/691e4mMKz9mtQ7v9qdjuA==} resolution: {integrity: sha512-804mFz2AFV/H9ysmo7dLqMjSGOQgREsgQIuep+Xg+yNQeQtnUOYntElEzlB798Sj/691e4mMKz9mtQ7v9qdjuA==}
@ -3244,10 +3208,6 @@ packages:
resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0: find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -3926,10 +3886,6 @@ packages:
resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4237,10 +4193,6 @@ packages:
resolution: {integrity: sha512-zL7VE4JVS2IFSkR2GQKDSPEVxkoH43/p7oEnwpdCndKYJO0HVeRB7fA8TJwuLOTBREtK0ea8eHaxdwcpob5dmg==} resolution: {integrity: sha512-zL7VE4JVS2IFSkR2GQKDSPEVxkoH43/p7oEnwpdCndKYJO0HVeRB7fA8TJwuLOTBREtK0ea8eHaxdwcpob5dmg==}
engines: {node: '>=4'} engines: {node: '>=4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0: p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4249,10 +4201,6 @@ packages:
resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0: p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4265,10 +4213,6 @@ packages:
resolution: {integrity: sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==} resolution: {integrity: sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==}
engines: {node: '>=4'} engines: {node: '>=4'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json@6.5.0: package-json@6.5.0:
resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==} resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -4379,10 +4323,6 @@ packages:
platform@1.3.6: platform@1.3.6:
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0: possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4522,11 +4462,6 @@ packages:
resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==}
engines: {node: '>=20'} engines: {node: '>=20'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
query-string@5.1.1: query-string@5.1.1:
resolution: {integrity: sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==} resolution: {integrity: sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -4641,9 +4576,6 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -4776,9 +4708,6 @@ packages:
resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==}
engines: {node: '>=10'} engines: {node: '>=10'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -5361,9 +5290,6 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.22: which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -5416,9 +5342,6 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -5431,10 +5354,6 @@ packages:
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@20.2.9: yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -5443,10 +5362,6 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@16.2.0: yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -7506,8 +7421,6 @@ snapshots:
dependencies: dependencies:
'@types/node': 25.9.2 '@types/node': 25.9.2
'@types/crypto-js@4.2.2': {}
'@types/debug@4.1.13': '@types/debug@4.1.13':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
@ -7552,10 +7465,6 @@ snapshots:
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 25.9.2
'@types/react@18.3.31': '@types/react@18.3.31':
dependencies: dependencies:
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
@ -8114,8 +8023,6 @@ snapshots:
pascal-case: 3.1.2 pascal-case: 3.1.2
tslib: 2.8.1 tslib: 2.8.1
camelcase@5.3.1: {}
caniuse-lite@1.0.30001797: {} caniuse-lite@1.0.30001797: {}
capital-case@1.0.4: capital-case@1.0.4:
@ -8218,12 +8125,6 @@ snapshots:
cli-width@3.0.0: {} cli-width@3.0.0: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@7.0.4: cliui@7.0.4:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
@ -8347,8 +8248,6 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
crypto-js@4.2.0: {}
css-functions-list@3.3.3: {} css-functions-list@3.3.3: {}
css-tree@3.2.1: css-tree@3.2.1:
@ -8388,8 +8287,6 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decamelize@1.2.0: {}
decode-uri-component@0.2.2: {} decode-uri-component@0.2.2: {}
decode-uri-component@0.4.1: {} decode-uri-component@0.4.1: {}
@ -8467,8 +8364,6 @@ snapshots:
detect-libc@2.1.2: detect-libc@2.1.2:
optional: true optional: true
dijkstrajs@1.0.3: {}
dingtalk-jsapi@2.15.6: dingtalk-jsapi@2.15.6:
dependencies: dependencies:
promise-polyfill: 7.1.2 promise-polyfill: 7.1.2
@ -9007,11 +8902,6 @@ snapshots:
filter-obj@5.1.0: {} filter-obj@5.1.0: {}
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0: find-up@5.0.0:
dependencies: dependencies:
locate-path: 6.0.0 locate-path: 6.0.0
@ -9726,10 +9616,6 @@ snapshots:
loader-utils@3.3.1: {} loader-utils@3.3.1: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
@ -10013,10 +9899,6 @@ snapshots:
p-is-promise@1.1.0: {} p-is-promise@1.1.0: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0: p-limit@3.1.0:
dependencies: dependencies:
yocto-queue: 0.1.0 yocto-queue: 0.1.0
@ -10025,10 +9907,6 @@ snapshots:
dependencies: dependencies:
yocto-queue: 1.2.2 yocto-queue: 1.2.2
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0: p-locate@5.0.0:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
@ -10041,8 +9919,6 @@ snapshots:
dependencies: dependencies:
p-finally: 1.0.0 p-finally: 1.0.0
p-try@2.2.0: {}
package-json@6.5.0: package-json@6.5.0:
dependencies: dependencies:
got: 9.6.0 got: 9.6.0
@ -10135,8 +10011,6 @@ snapshots:
platform@1.3.6: {} platform@1.3.6: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-css-variables@0.19.0(postcss@8.5.15): postcss-css-variables@0.19.0(postcss@8.5.15):
@ -10258,12 +10132,6 @@ snapshots:
dependencies: dependencies:
hookified: 2.2.0 hookified: 2.2.0
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
query-string@5.1.1: query-string@5.1.1:
dependencies: dependencies:
decode-uri-component: 0.2.2 decode-uri-component: 0.2.2
@ -10396,8 +10264,6 @@ snapshots:
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-from@5.0.0: {} resolve-from@5.0.0: {}
@ -10574,8 +10440,6 @@ snapshots:
seroval@1.5.4: {} seroval@1.5.4: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@ -11225,8 +11089,6 @@ snapshots:
is-weakmap: 2.0.2 is-weakmap: 2.0.2
is-weakset: 2.0.4 is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.22: which-typed-array@1.1.22:
dependencies: dependencies:
available-typed-arrays: 1.0.7 available-typed-arrays: 1.0.7
@ -11287,37 +11149,16 @@ snapshots:
xtend@4.0.2: {} xtend@4.0.2: {}
y18n@4.0.3: {}
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@3.1.1: {} yallist@3.1.1: {}
yaml@2.9.0: {} yaml@2.9.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@20.2.9: {} yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@16.2.0: yargs@16.2.0:
dependencies: dependencies:
cliui: 7.0.4 cliui: 7.0.4

View File

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

View File

@ -1,98 +0,0 @@
import Taro from '@tarojs/taro'
import { request } from '@/lib/request'
import { applyCorpInit } from '@/lib/session'
import { buildFileUrl, getFileExtension, pickPresignResult, QINIU_UPLOAD_URL } from '@/lib/qiniu'
export function getUserConfig (params: Record<string, any> = {}) {
return request({
url: '/api/user/config',
method: 'GET',
params,
})
}
export function initCorp (params: Record<string, any> = {}) {
return request({
url: '/api/h5/corp',
method: 'GET',
params,
}).then((res) => {
applyCorpInit(res)
return res
})
}
export function getCompanies (keyword: string) {
return request({
url: '/api/h5/company',
method: 'GET',
params: { keyword },
})
}
export function report (data: {
contact: string
content: string
images?: string[]
type: string
[property: string]: any
}) {
return request({
url: '/api/user/feedback',
method: 'POST',
data,
})
}
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),
}
}

31
src/app.config.test.ts Normal file
View File

@ -0,0 +1,31 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(() => vi.unstubAllGlobals())
describe('A5 application entry', () => {
it('registers the A5 home and its supporting pages at canonical paths', async () => {
vi.stubGlobal('defineAppConfig', (config: unknown) => config)
const { default: config } = await import('./app.config')
expect(config.pages).toEqual([
'pages/index/index',
'pages/resubmit/index',
'pages/complaint/index',
'pages/map/index',
'pages/agreement/index',
])
config.pages?.forEach((page) => {
expect(existsSync(resolve('src', `${page}.tsx`))).toBe(true)
expect(existsSync(resolve('src', `${page}.config.ts`))).toBe(true)
})
expect(existsSync(resolve('src/pages/a5'))).toBe(false)
})
it.each(['development', 'production'])('keeps only A5 business environment variables in %s', (mode) => {
const env = readFileSync(resolve(`.env.${mode}`), 'utf8')
const keys = env.split(/\r?\n/).filter((line) => line.startsWith('TARO_APP_')).map((line) => line.split('=')[0])
expect(keys.sort()).toEqual(['TARO_APP_API_A5', 'TARO_APP_TIANDITU_KEY'])
})
})

View File

@ -1,9 +1,6 @@
export default defineAppConfig({ export default defineAppConfig({
pages: [ pages: [
'pages/index/index', 'pages/index/index',
'pages/a5/index',
'pages/a5/resubmit/index',
'pages/a5/complaint/index',
'pages/resubmit/index', 'pages/resubmit/index',
'pages/complaint/index', 'pages/complaint/index',
'pages/map/index', 'pages/map/index',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 965 B

View File

@ -4,14 +4,17 @@ import { A5_STATUSES, A5_TABS, adaptA5Files, adaptA5Order, buildA5Update, parseA
describe('A5 launch', () => { describe('A5 launch', () => {
it('accepts the H5 contract without injecting corp defaults', () => { it('accepts the H5 contract without injecting corp defaults', () => {
expect(parseA5Launch({ phone: '13800138000', link_id: '42', token: 'corp-token' })).toEqual({ phone: '13800138000', linkId: '42' }) expect(parseA5Launch({ phone: '13800138000', link_id: '42', token: 'corp-token' })).toEqual({ phone: '13800138000', linkId: '42' })
expect(parseA5Launch({})).toEqual({ phone: '', linkId: '' }) expect(parseA5Launch({})).toEqual({ phone: '', linkId: '2x6' })
expect(parseA5Launch({ phone: '13800138000' })).toEqual({ phone: '13800138000', linkId: '2x6' })
expect(parseA5Launch({ link_id: '' }).linkId).toBe('2x6')
expect(parseA5Launch({ linkId: 'custom' }).linkId).toBe('custom')
}) })
it('supports legacy encoded scenes and URL Link separators', () => { it('supports legacy encoded scenes and URL Link separators', () => {
expect(parseA5Launch({ scene: '%2Fh%2F42%26phone%3D13800138000' })).toEqual({ phone: '13800138000', linkId: '42' }) expect(parseA5Launch({ scene: '%2Fh%2F42%26phone%3D13800138000' })).toEqual({ phone: '13800138000', linkId: '42' })
expect(parseA5Launch({ phone: '13800138000%26link_id=42' })).toEqual({ phone: '13800138000', linkId: '42' }) expect(parseA5Launch({ phone: '13800138000%26link_id=42' })).toEqual({ phone: '13800138000', linkId: '42' })
expect(parseA5Launch({ phone: '13800138000&link_id=old', link_id: 'new' }).linkId).toBe('new') expect(parseA5Launch({ phone: '13800138000&link_id=old', link_id: 'new' }).linkId).toBe('new')
expect(parseA5Launch({ scene: '/h/old', link_id: 'new' }).linkId).toBe('new') expect(parseA5Launch({ scene: '/h/old', link_id: 'new' }).linkId).toBe('new')
expect(parseA5Launch({ scene: '/p/corp' }).linkId).toBe('') expect(parseA5Launch({ scene: '/p/corp' }).linkId).toBe('2x6')
}) })
}) })
describe('A5 responses', () => { describe('A5 responses', () => {

View File

@ -27,7 +27,7 @@ export function parseA5Launch (raw: Record<string, string | undefined>) {
const scene = text(expanded.scene) const scene = text(expanded.scene)
return { return {
phone: text(expanded.phone), phone: text(expanded.phone),
linkId: text(expanded.link_id || expanded.linkId) || scene.match(/^\/h\/([^/?#&]+)/)?.[1] || '', linkId: text(expanded.link_id || expanded.linkId) || scene.match(/^\/h\/([^/?#&]+)/)?.[1] || '2x6',
} }
} }
export function unwrapA5Response (status: number, raw: any) { export function unwrapA5Response (status: number, raw: any) {

View File

@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const storage = vi.hoisted(() => ({ getStorageSync: vi.fn(), setStorageSync: vi.fn(), removeStorageSync: vi.fn() }))
vi.mock('@tarojs/taro', () => ({ default: storage }))
import { clearQueryPhone, getQueryPhone, saveQueryPhone } from './queryPhone'
beforeEach(() => vi.resetAllMocks())
describe('A5 query phone persistence', () => {
it('restores the saved phone on direct entry', () => {
storage.getStorageSync.mockReturnValue('13800138000')
expect(getQueryPhone()).toBe('13800138000')
expect(storage.getStorageSync).toHaveBeenCalledWith('a5QueryPhone')
})
it('gives the launch phone priority over storage', () => {
storage.getStorageSync.mockReturnValue('13800138000')
expect(getQueryPhone('13900139000')).toBe('13900139000')
expect(storage.getStorageSync).not.toHaveBeenCalled()
})
it.each([undefined, null, '', '138', 13800138000, { phone: '13800138000' }])('ignores invalid cached data: %j', (value) => {
storage.getStorageSync.mockReturnValue(value)
expect(getQueryPhone()).toBe('')
})
it('persists valid query phones across subsequent reads', () => {
const values = new Map<string, string>()
storage.setStorageSync.mockImplementation((key, value) => values.set(key, value))
storage.getStorageSync.mockImplementation((key) => values.get(key))
saveQueryPhone('13800138000')
expect(getQueryPhone()).toBe('13800138000')
saveQueryPhone('13900139000')
expect(getQueryPhone()).toBe('13900139000')
saveQueryPhone('139')
expect(getQueryPhone()).toBe('13900139000')
})
it('removes the saved phone when explicitly cleared', () => {
clearQueryPhone()
expect(storage.removeStorageSync).toHaveBeenCalledWith('a5QueryPhone')
})
it('tolerates storage errors', () => {
const fail = () => { throw new Error('Storage unavailable') }
storage.getStorageSync.mockImplementation(fail)
storage.setStorageSync.mockImplementation(fail)
storage.removeStorageSync.mockImplementation(fail)
expect(getQueryPhone()).toBe('')
expect(() => saveQueryPhone('13800138000')).not.toThrow()
expect(() => clearQueryPhone()).not.toThrow()
})
})

31
src/lib/a5/queryPhone.ts Normal file
View File

@ -0,0 +1,31 @@
import Taro from '@tarojs/taro'
import { isValidPhone } from '../phone'
const PHONE_KEY = 'a5QueryPhone'
export function getQueryPhone (launchPhone = ''): string {
if (launchPhone) return launchPhone
try {
const stored = Taro.getStorageSync(PHONE_KEY)
return typeof stored === 'string' && isValidPhone(stored) ? stored : ''
} catch {
return ''
}
}
export function saveQueryPhone (phone: string) {
if (!isValidPhone(phone)) return
try {
Taro.setStorageSync(PHONE_KEY, phone)
} catch {
// Storage failure must not prevent the current query.
}
}
export function clearQueryPhone () {
try {
Taro.removeStorageSync(PHONE_KEY)
} catch {
// The input can still be cleared when storage is unavailable.
}
}

View File

@ -18,7 +18,6 @@ import iconComplaintTimeoutActive from '@/assets/images/icon-complaint-timeout-a
import iconKefu from '@/assets/images/icon-kefu.png' import iconKefu from '@/assets/images/icon-kefu.png'
import iconInputClear from '@/assets/images/icon-input-clear.png' import iconInputClear from '@/assets/images/icon-input-clear.png'
import iconLogo from '@/assets/images/icon-logo.png' import iconLogo from '@/assets/images/icon-logo.png'
import iconPen from '@/assets/images/icon-pen.png'
import iconSearch from '@/assets/images/icon-search.png' import iconSearch from '@/assets/images/icon-search.png'
export const LOCAL_IMAGES = { export const LOCAL_IMAGES = {
@ -42,6 +41,5 @@ export const LOCAL_IMAGES = {
kefu: iconKefu, kefu: iconKefu,
clear: iconInputClear, clear: iconInputClear,
logo: iconLogo, logo: iconLogo,
pen: iconPen,
search: iconSearch, search: iconSearch,
} }

View File

@ -1,47 +0,0 @@
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('获取临时登录态失败')
})
})

View File

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

View File

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

View File

@ -2,12 +2,7 @@ export const STATIC_CDN = 'https://cdn.u8t.cn/frontend-static/corp-h5/static'
export const CORP_ASSET_BASE = `${STATIC_CDN}/package-v3` export const CORP_ASSET_BASE = `${STATIC_CDN}/package-v3`
export const MAP_ASSET_BASE = `${STATIC_CDN}/package-v4` export const MAP_ASSET_BASE = `${STATIC_CDN}/package-v4`
export const HOME_ICON_BASE = `${MAP_ASSET_BASE}/images-pc/home` 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 PAGE_TITLE = '敌无双企业服务'
export const RECEIPT_STAMP_DONE = `${STATIC_CDN}/images/oderDetailCallBackSuccess.png`
export const RECEIPT_STAMP_PENDING = `${STATIC_CDN}/images/oderDetailCallBack.png`
export const ENTITY_LABELS = { export const ENTITY_LABELS = {
entityName: '门店名称', entityName: '门店名称',

View File

@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildCorpResubmitPayload, getCorpResubmitValues, validateIDCard } from './company'
describe('corp resubmit', () => {
const order = {
order_id: '12',
entity_name: '测试有限公司',
entity_id: '91330100MA2TEST001',
entity_legal: '张三',
entity_idno: '11010519491231002X',
entity_phone: '13800138000',
}
it('initializes the company form from the queried order', () => {
expect(getCorpResubmitValues(order)).toEqual({
entity_name: '测试有限公司',
entity_id: '91330100MA2TEST001',
entity_legal: '张三',
entity_idno: '11010519491231002X',
entity_phone: '13800138000',
})
})
it('matches the H5 update-order payload', () => {
expect(buildCorpResubmitPayload(order, getCorpResubmitValues(order))).toEqual({
id: '12',
entity_extra: {},
entity_id: '91330100MA2TEST001',
entity_idno: '11010519491231002X',
entity_legal: '张三',
entity_name: '测试有限公司',
entity_phone: '13800138000',
})
})
})
describe('validateIDCard', () => {
it('validates the 18 digit checksum and accepts a lowercase x', () => {
expect(validateIDCard('11010519491231002X')).toBe(true)
expect(validateIDCard('11010519491231002x')).toBe(true)
expect(validateIDCard('110105194912310021')).toBe(false)
expect(validateIDCard('not-an-id')).toBe(false)
})
})

View File

@ -1,54 +0,0 @@
import type { OrderLike } from './order'
export type CompanyItem = {
name: string
merge_name?: string
color_name?: string
credit_code: string
oper_name: string
start_date?: string
status?: string
}
export type CorpResubmitValues = {
entity_name: string
entity_id: string
entity_legal: string
entity_idno: string
entity_phone: string
}
export function getCorpResubmitValues (order: OrderLike): CorpResubmitValues {
return {
entity_name: String(order.entity_name || ''),
entity_id: String(order.entity_id || ''),
entity_legal: String(order.entity_legal || ''),
entity_idno: String(order.entity_idno || ''),
entity_phone: String(order.entity_phone || ''),
}
}
export function validateIDCard (id: string) {
const normalized = String(id || '').trim().toUpperCase()
if (!/^\d{17}[\dX]$/.test(normalized)) return false
const coefficients = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = coefficients.reduce((total, coefficient, index) => {
return total + Number(normalized[index]) * coefficient
}, 0)
return checkCodes[sum % 11] === normalized[17]
}
export function buildCorpResubmitPayload (order: OrderLike, values: CorpResubmitValues) {
return {
id: String(order.order_id || ''),
entity_extra: {},
entity_id: values.entity_id,
entity_idno: values.entity_idno,
entity_legal: values.entity_legal,
entity_name: values.entity_name,
entity_phone: values.entity_phone,
}
}

View File

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

View File

@ -1,21 +0,0 @@
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)
})
})

View File

@ -1,18 +0,0 @@
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)
}

View File

@ -1,85 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getReceiptMeta, numberToUpperCase, pickGoodsType, shouldShowReceipt, splitOrderFiles } from './detail'
describe('splitOrderFiles', () => {
it('separates screenshot images from pdf reports', () => {
expect(splitOrderFiles([
{ id: '1', url: 'https://cdn.example/a.jpg' },
{ id: '2', url: 'http://cdn.example/report.pdf' },
{ id: '3', url: 'https://cdn.example/b.png?ver=1' },
{ id: '4', url: 'https://cdn.example/note.PDF' },
])).toEqual({
images: [
{ id: '1', url: 'https://cdn.example/a.jpg' },
{ id: '3', url: 'https://cdn.example/b.png?ver=1' },
],
pdfs: [
{ id: '2', url: 'https://cdn.example/report.pdf' },
{ id: '4', url: 'https://cdn.example/note.PDF' },
],
})
})
it('returns empty groups when files are missing', () => {
expect(splitOrderFiles()).toEqual({ images: [], pdfs: [] })
expect(splitOrderFiles([])).toEqual({ images: [], pdfs: [] })
})
})
describe('shouldShowReceipt', () => {
it('shows receipt for corp goods types and hides it for map_label', () => {
expect(shouldShowReceipt('license_year')).toBe(true)
expect(shouldShowReceipt('license_destory')).toBe(true)
expect(shouldShowReceipt('credit_repair')).toBe(true)
expect(shouldShowReceipt('map_label')).toBe(false)
expect(shouldShowReceipt('unknown')).toBe(false)
expect(shouldShowReceipt()).toBe(false)
})
})
describe('getReceiptMeta', () => {
it('returns title and remark for each corp goods type', () => {
expect(getReceiptMeta('license_year').title).toBe('信息申报回执单')
expect(getReceiptMeta('license_year').description).toContain('年报国家公示官网')
expect(getReceiptMeta('license_destory').title).toBe('工商信息注销回执单')
expect(getReceiptMeta('license_destory').description).toContain('登记状态')
expect(getReceiptMeta('credit_repair').title).toBe('工商信息修复回执单')
expect(getReceiptMeta('credit_repair').description).toContain('creditchina.gov.cn')
})
it('falls back to goods name when type is unknown', () => {
expect(getReceiptMeta('unknown', '自定义业务')).toEqual({
title: '自定义业务回执单',
description: '',
})
})
})
describe('numberToUpperCase', () => {
it('converts receipt amounts the same way as H5', () => {
expect(numberToUpperCase('')).toBe('')
expect(numberToUpperCase('0')).toBe('零元整')
expect(numberToUpperCase('1')).toBe('壹元整')
expect(numberToUpperCase('10')).toBe('壹拾元整')
expect(numberToUpperCase('100.5')).toBe('壹佰元伍角')
expect(numberToUpperCase('123.45')).toBe('壹佰贰拾叁元肆角伍分')
})
})
describe('pickGoodsType', () => {
it('prefers prev_info goods_type then first goods item', () => {
expect(pickGoodsType({
data: {
prev_info: { goods_type: 'license_year' },
goods: [{ goods_type: 'map_label' }],
},
})).toBe('license_year')
expect(pickGoodsType({
prevInfo: { type: 'credit_repair' },
})).toBe('credit_repair')
expect(pickGoodsType({
data: { goods: [{ goods_type: 'map_label' }] },
})).toBe('map_label')
expect(pickGoodsType({})).toBe('')
})
})

View File

@ -1,146 +0,0 @@
export const CORP_GOODS_TYPES = ['license_year', 'license_destory', 'credit_repair'] as const
export type CorpGoodsType = (typeof CORP_GOODS_TYPES)[number]
const RECEIPT_META: Record<CorpGoodsType, { title: string, description: string }> = {
credit_repair: {
title: '工商信息修复回执单',
description:
'信用网站官网 https://www.creditchina.gov.cn/\n' +
'搜索企业: 在首页搜索框输入公司全称或统一社会信用代码进行查询\n' +
'查看信用信息: 进入企业信用信息详情页;\n' +
'重点关注:\n' +
'1."行政处罚"栏目: 查看该条被修复的行政处罚信息状态。如果修复成功,通常会显示"已修复"、"已信用修复"、"已撤下"等状态标识,或者该条信息已不再显示在公示列表中;\n' +
'2."信用修复"栏目(如果有): 有些平台会单独列出企业的信用修复申请记录及状态(如:申请中、审核通过/修复成功、审核不通过);\n' +
'3.查看"失信信息"或"重点关注名单" 如果该处罚曾导致企业被列入失信名单或重点关注名单,确认这些名单中是否已移除该公司;',
},
license_destory: {
title: '工商信息注销回执单',
description:
'访问官网http://www.gsxt.gov.cn输入企业名称或统一社会信用代码进行搜索。\n' +
'在企业详情页面查看“登记状态”栏是否标注为“注销”\n' +
'注意注销信息通常需1-3天完成公示更新建议完成注销流程后次日查询。',
},
license_year: {
title: '信息申报回执单',
description:
'年报国家公示官网https://www.gsxt.gov.cn\n' +
'1.年报公示日期为7个工作日到期后可自行登陆国家企业信用信息官网查询\n' +
'2.您的个人信息(姓名、电话、身份证),只用于您办理业务时使用;\n' +
'3.企业一站式服务平台为第三方企业服务公司,办理业务方便快捷,办理不成功退全部费用;',
},
}
export function isCorpGoodsType (goodsType?: string): goodsType is CorpGoodsType {
return (CORP_GOODS_TYPES as readonly string[]).includes(goodsType || '')
}
export function shouldShowReceipt (goodsType?: string) {
return isCorpGoodsType(goodsType)
}
export function pickGoodsType (payload: any): string {
const data = payload?.data ?? payload ?? {}
const prev = data.prev_info ?? data.prevInfo ?? payload?.prevInfo ?? {}
const fromPrev = String(prev.goods_type || prev.type || '').trim()
if (fromPrev) return fromPrev
const goods = Array.isArray(data.goods) ? data.goods : []
const first = goods[0] || {}
return String(first.goods_type || first.type || '').trim()
}
export function getReceiptMeta (goodsType?: string, goodsName?: string) {
if (isCorpGoodsType(goodsType)) {
return RECEIPT_META[goodsType]
}
const name = String(goodsName || '').trim()
return {
title: name ? `${name}回执单` : '回执单',
description: '',
}
}
export type OrderFile = {
id?: string
url?: string
}
function isPdfUrl (url: string) {
return /\.pdf(?:$|[?#])/i.test(url)
}
export function splitOrderFiles (files?: OrderFile[] | null) {
const images: OrderFile[] = []
const pdfs: OrderFile[] = []
for (const file of files || []) {
const url = typeof file?.url === 'string' ? file.url.replace(/^http:\/\//i, 'https://') : ''
if (!url) continue
const nextFile = url === file.url ? file : { ...file, url }
if (isPdfUrl(url)) pdfs.push(nextFile)
else images.push(nextFile)
}
return { images, pdfs }
}
export function numberToUpperCase (money: unknown): string {
const cnNums = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']
const cnIntRadice = ['', '拾', '佰', '仟']
const cnIntUnits = ['', '万', '亿', '兆']
const cnDecUnits = ['角', '分']
const cnInteger = '整'
const cnIntLast = '元'
if (money === '' || money === undefined || money === null) return ''
const text = String(money)
const cashText = text.charAt(0) === '-' ? text.slice(1) : text
let cash = parseFloat(cashText)
if (cash === 0) return `${cnNums[0]}${cnIntLast}${cnInteger}`
if (!Number.isFinite(cash)) return ''
const [integerNum, rawDecimal = ''] = cash.toString().split('.')
const decimalNum = rawDecimal.substr(0, 2)
let chineseStr = ''
if (integerNum !== '-' && parseInt(integerNum, 10) > 0) {
const intLen = integerNum.length
let zero = 0
for (let i = 0; i < intLen; i++) {
const intChar = integerNum.substr(i, 1)
const intSlen = intLen - i - 1
const divided = intSlen / 4
const remain = intSlen % 4
if (intChar === '0') {
zero += 1
} else {
if (zero > 0) chineseStr += cnNums[0]
zero = 0
chineseStr += cnNums[parseInt(intChar, 10)] + cnIntRadice[remain]
}
if (remain === 0 && divided > 0) {
chineseStr += cnIntUnits[divided]
}
}
chineseStr += cnIntLast
}
if (decimalNum) {
for (let i = 0; i < decimalNum.length; i++) {
const decChar = decimalNum.substr(i, 1)
if (decChar !== '0') {
chineseStr += cnNums[parseInt(decChar, 10)] + cnDecUnits[i]
}
if (decChar === '0' && parseInt(integerNum, 10) > 0) {
chineseStr += cnNums[0] + cnDecUnits[i]
}
}
} else {
chineseStr += cnInteger
}
return chineseStr
}

View File

@ -1,166 +0,0 @@
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: 'wxf8a8961f30396c19',
path: 'pages/index/index',
scene: '/h/KZ9Q',
token: '',
host: 'nb.batiao8.com',
package: '10044',
phone: '',
})
})
})
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('unpacks jump params stuffed into scene when & is encoded as %26', () => {
expect(
parseLaunchQuery({
scene:
'%2Fh%2FKZ9Q%26token=67738e89-4f74-4d9b-bdcc-1d30dc11f0fd%26host=nb.batiao8.com%26package=10044%26phone=17316703138',
})
).toEqual({
token: '67738e89-4f74-4d9b-bdcc-1d30dc11f0fd',
host: 'nb.batiao8.com',
packageId: '10044',
scene: '/h/KZ9Q',
linkId: 'KZ9Q',
shareId: '',
prevId: '',
phone: '17316703138',
})
})
it('unpacks jump params after WeChat decodes %26 into &', () => {
expect(
parseLaunchQuery({
scene:
'/h/KZ9Q&token=67738e89-4f74-4d9b-bdcc-1d30dc11f0fd&host=nb.batiao8.com&package=10044&phone=17316703138',
})
).toEqual({
token: '67738e89-4f74-4d9b-bdcc-1d30dc11f0fd',
host: 'nb.batiao8.com',
packageId: '10044',
scene: '/h/KZ9Q',
linkId: 'KZ9Q',
shareId: '',
prevId: '',
phone: '17316703138',
})
})
it('keeps explicit query fields over values stuffed into scene', () => {
expect(
parseLaunchQuery({
scene: '/h/KZ9Q&token=from-scene&phone=13800138000',
token: 'from-query',
}).token
).toBe('from-query')
})
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: '',
host: 'nb.batiao8.com',
packageId: '10044',
scene: '/h/KZ9Q',
linkId: 'KZ9Q',
shareId: '',
prevId: '',
phone: '',
})
})
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: '',
})
})
it('uses stuffed scene params instead of scheme defaults', () => {
expect(
resolveLaunchQuery({
scene:
'/h/KZ9Q&token=h5-token&host=h5test.batiao8.com&package=20001&phone=17316703138',
})
).toEqual({
token: 'h5-token',
host: 'h5test.batiao8.com',
packageId: '20001',
scene: '/h/KZ9Q',
linkId: 'KZ9Q',
shareId: '',
prevId: '',
phone: '17316703138',
})
})
})

View File

@ -1,104 +0,0 @@
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=wxf8a8961f30396c19&path=pages/index/index&query=scene%3D%2Fh%2FKZ9Q%26host%3Dnb.batiao8.com%26package%3D10044'
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 }
}
function expandLaunchRaw (raw: Record<string, string | undefined> = {}) {
const scene = asText(raw.scene)
if (!scene) return raw
const decoded = safeDecode(scene).replace(/%26/gi, '&')
const amp = decoded.indexOf('&')
if (amp < 0) return raw
const extra: Record<string, string> = {}
new URLSearchParams(decoded.slice(amp + 1)).forEach((value, key) => {
extra[key] = value
})
return {
...extra,
...raw,
scene: decoded.slice(0, amp),
}
}
export function parseLaunchQuery (raw: Record<string, string | undefined> = {}): LaunchQuery {
const expanded = expandLaunchRaw(raw)
const scene = safeDecode(asText(expanded.scene))
return {
token: asText(expanded.token),
host: asText(expanded.host),
packageId: asText(expanded.package),
scene,
phone: asText(expanded.phone),
...parseSceneIds(scene),
}
}
export function resolveLaunchQuery (raw: Record<string, string | undefined> = {}): LaunchQuery {
const parsed = parseLaunchQuery(raw)
const defaults = parseLaunchQuery(parseMiniProgramScheme(DEFAULT_LAUNCH_SCHEME))
const scene = parsed.scene || defaults.scene
const sceneIds = parsed.scene
? { linkId: parsed.linkId, shareId: parsed.shareId, prevId: parsed.prevId }
: { linkId: defaults.linkId, shareId: defaults.shareId, prevId: defaults.prevId }
return {
token: parsed.token || defaults.token,
host: parsed.host || defaults.host,
packageId: parsed.packageId || defaults.packageId,
scene,
phone: parsed.phone || defaults.phone,
...sceneIds,
}
}

View File

@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { adaptA5Order } from './a5/data' import { adaptA5Order } from './a5/data'
import { import {
buildResubmitPayload,
filterMapLabelOrders,
filterOrdersForProduct,
formatMapCoordinate, formatMapCoordinate,
formatMapCoordinateDisplay, formatMapCoordinateDisplay,
getOrderAddressName, getOrderAddressName,
@ -56,47 +53,6 @@ describe('order display name', () => {
}) })
}) })
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('filterOrdersForProduct', () => {
it('keeps all three corp products together when querying from a corp entry', () => {
const orders = filterOrdersForProduct('license_year', [
mapOrder,
{ ...mapOrder, order_id: '5', goods_type: 'license_year' },
{ ...mapOrder, order_id: '6', goods_type: 'license_destory' },
{ ...mapOrder, order_id: '7', goods_type: 'credit_repair' },
] as any)
expect(orders.map((item) => item.order_id)).toEqual(['4', '5', '6', '7'])
expect(orders[0].extra.goods_param).toEqual({
'client.goods.icon': ['https://cdn.example/i.png'],
})
})
it('keeps only matching orders for map_label and unknown types', () => {
const mixed = [
mapOrder,
{ ...mapOrder, order_id: '5', goods_type: 'license_year' },
] as any
expect(filterOrdersForProduct('map_label', mixed).map((item) => item.order_id)).toEqual(['4'])
expect(filterOrdersForProduct('', mixed).map((item) => item.order_id)).toEqual(['4'])
expect(filterOrdersForProduct('unknown', mixed)).toEqual([])
})
})
describe('order address helpers', () => { describe('order address helpers', () => {
it('splits named address and coordinate address', () => { it('splits named address and coordinate address', () => {
expect(getOrderAddressName(mapOrder as any)).toBe('南京路1号') expect(getOrderAddressName(mapOrder as any)).toBe('南京路1号')
@ -158,39 +114,3 @@ describe('map coordinate display', () => {
expect(formatMapCoordinateDisplay({ lng: 120.156892, lat: 30.278956 })).toBe('N30.278956°, E120.156892°') expect(formatMapCoordinateDisplay({ lng: 120.156892, lat: 30.278956 })).toBe('N30.278956°, E120.156892°')
}) })
}) })
describe('buildResubmitPayload', () => {
it('updates extra while dropping upload-only keys and keeping storefront 0 placeholders', () => {
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',
},
storefrontSlots: [
{ id: '123', url: 'https://cdn.example/c.jpg', preview: 'p' },
{ id: '45', url: 'https://cdn.example/d.jpg', preview: 'p' },
null,
],
licenseImages: [{ id: '31', url: 'https://cdn.example/e.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: '123,45,0',
entity_business_license_image: '31',
},
})
})
})

View File

@ -1,5 +1,3 @@
import { isCorpGoodsType } from './detail'
export type UploadedImage = { export type UploadedImage = {
id: string id: string
url: string url: string
@ -25,18 +23,6 @@ export type ResubmitValues = {
entity_phone2?: 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) { function splitValues (value: unknown) {
if (value === undefined || value === null) return [] if (value === undefined || value === null) return []
return String(value) return String(value)
@ -122,41 +108,10 @@ export function getInitialPhone2 (order: OrderLike) {
return String(extra.entity_phone2 || extra.entity_phone_2 || extra.phone2 || '') 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 normalizeOrders<T extends OrderLike> (orders: T[] = []) {
return orders.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 getOrderDisplayName (order?: OrderLike | null) { export function getOrderDisplayName (order?: OrderLike | null) {
return String(order?.goods_name || '').trim() || '-' return String(order?.goods_name || '').trim() || '-'
} }
export function filterOrdersForProduct<T extends OrderLike> (goodsType: string, orders: T[] = []) {
const normalized = normalizeOrders(orders)
if (isCorpGoodsType(goodsType)) return normalized
const target = goodsType || 'map_label'
return normalized.filter((item) => item.goods_type === target)
}
export function filterMapLabelOrders<T extends OrderLike> (orders: T[] = []) {
return filterOrdersForProduct('map_label', orders)
}
export function getOrderExtraImages (order: OrderLike | null | undefined, keys: string[]): UploadedImage[] { export function getOrderExtraImages (order: OrderLike | null | undefined, keys: string[]): UploadedImage[] {
const extra = order?.extra || {} const extra = order?.extra || {}
@ -189,33 +144,3 @@ export function joinUploadedImageIds (images: UploadedImage[]) {
.filter(Boolean) .filter(Boolean)
.join(',') .join(',')
} }
export function buildResubmitPayload (input: {
order: OrderLike
values: ResubmitValues
storefrontSlots: StorefrontSlot[]
licenseImages: UploadedImage[]
}) {
const existingExtra = Object.entries(input.order.extra || {}).reduce<Record<string, unknown>>((result, [key, value]) => {
if (!OMIT_EXTRA_KEYS.has(key)) {
result[key] = value
}
return result
}, {})
return {
id: String(input.order.order_id || ''),
corp_id: 0,
entity_name: input.values.entity_name || input.order.entity_name || '',
entity_phone: input.values.entity_phone || '',
entity_extra: input.order.entity_extra || {},
extra: {
...existingExtra,
entity_address_name: input.values.entity_address_name || '',
entity_address: input.values.entity_address || '',
entity_phone2: input.values.entity_phone2 || '',
entity_storefront_image: joinStorefrontSlots(input.storefrontSlots),
entity_business_license_image: joinUploadedImageIds(input.licenseImages),
},
}
}

View File

@ -1,29 +0,0 @@
import Taro from '@tarojs/taro'
import type { OrderLike } from './order'
const ORDER_DRAFT_KEY = 'orderDraft'
let memoryDraft: OrderLike | null = null
export function setOrderDraft (order: OrderLike) {
memoryDraft = order
try {
Taro.setStorageSync(ORDER_DRAFT_KEY, order)
} catch {
// ignore quota / private-mode failures; in-memory draft still works
}
}
export function getOrderDraft (): OrderLike | null {
if (memoryDraft) return memoryDraft
try {
const stored = Taro.getStorageSync(ORDER_DRAFT_KEY)
if (stored && typeof stored === 'object') {
memoryDraft = stored
return stored
}
} catch {
// ignore
}
return null
}

View File

@ -1,56 +0,0 @@
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')
})
})

View File

@ -1,26 +0,0 @@
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)
}

View File

@ -1,31 +0,0 @@
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'
)
})
})

View File

@ -1,37 +0,0 @@
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'
}

View File

@ -1,10 +0,0 @@
import { describe, expect, it } from 'vitest'
import { bytesToBase64, encodeQrPng } from './qrPng'
describe('encodeQrPng', () => {
it('encodes a wechat personal link into a png buffer', () => {
const png = encodeQrPng('https://u.wechat.com/ELkCkT_hVVAz8wNghBA3hMc?s=2')
expect(Array.from(png.slice(0, 8))).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
expect(bytesToBase64(png).startsWith('iVBOR')).toBe(true)
})
})

View File

@ -1,129 +0,0 @@
import QRCode from 'qrcode'
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
const CRC_TABLE = makeCrcTable()
const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
export function encodeQrPng (text: string, scale = 6, margin = 4) {
const qr = QRCode.create(text, { errorCorrectionLevel: 'M' })
const moduleCount = qr.modules.size
const dim = (moduleCount + margin * 2) * scale
const raw = new Uint8Array((dim * 3 + 1) * dim)
let offset = 0
for (let y = 0; y < dim; y += 1) {
raw[offset] = 0
offset += 1
const moduleY = Math.floor(y / scale) - margin
for (let x = 0; x < dim; x += 1) {
const moduleX = Math.floor(x / scale) - margin
const dark = moduleX >= 0 && moduleX < moduleCount && moduleY >= 0 && moduleY < moduleCount
? Boolean(qr.modules.get(moduleX, moduleY))
: false
const value = dark ? 0 : 255
raw[offset] = value
raw[offset + 1] = value
raw[offset + 2] = value
offset += 3
}
}
const ihdr = [
...u32(dim),
...u32(dim),
8,
2,
0,
0,
0,
]
const idat = zlibStore(raw)
const bytes = new Uint8Array([
...PNG_SIGNATURE,
...chunk('IHDR', ihdr),
...chunk('IDAT', idat),
...chunk('IEND', []),
])
return bytes
}
export function bytesToBase64 (bytes: Uint8Array) {
let result = ''
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i]
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
const triple = (a << 16) | (b << 8) | c
result += BASE64_CHARS[(triple >> 18) & 63]
result += BASE64_CHARS[(triple >> 12) & 63]
result += i + 1 < bytes.length ? BASE64_CHARS[(triple >> 6) & 63] : '='
result += i + 2 < bytes.length ? BASE64_CHARS[triple & 63] : '='
}
return result
}
function zlibStore (raw: Uint8Array) {
const blocks: number[] = [0x78, 0x01]
const max = 65535
for (let i = 0; i < raw.length; i += max) {
const end = Math.min(i + max, raw.length)
const chunkData = raw.subarray(i, end)
const last = end === raw.length ? 1 : 0
const len = chunkData.length
blocks.push(last, len & 0xff, (len >> 8) & 0xff, (~len) & 0xff, ((~len) >> 8) & 0xff)
for (let j = 0; j < chunkData.length; j += 1) {
blocks.push(chunkData[j])
}
}
blocks.push(...u32(adler32(raw)))
return blocks
}
function chunk (type: string, data: number[]) {
const typeBytes = [type.charCodeAt(0), type.charCodeAt(1), type.charCodeAt(2), type.charCodeAt(3)]
return [
...u32(data.length),
...typeBytes,
...data,
...u32(crc32([...typeBytes, ...data])),
]
}
function u32 (value: number) {
return [
(value >>> 24) & 0xff,
(value >>> 16) & 0xff,
(value >>> 8) & 0xff,
value & 0xff,
]
}
function crc32 (bytes: number[]) {
let crc = 0xffffffff
for (let i = 0; i < bytes.length; i += 1) {
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8)
}
return (crc ^ 0xffffffff) >>> 0
}
function adler32 (bytes: Uint8Array) {
let a = 1
let b = 0
for (let i = 0; i < bytes.length; i += 1) {
a = (a + bytes[i]) % 65521
b = (b + a) % 65521
}
return ((b << 16) | a) >>> 0
}
function makeCrcTable () {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n += 1) {
let c = n
for (let k = 0; k < 8; k += 1) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)
}
table[n] = c >>> 0
}
return table
}

View File

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

View File

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

View File

@ -1,50 +0,0 @@
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)
})
})

View File

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

View File

@ -1,97 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
filterOrdersByTab,
getOrderCardActions,
getStatusMeta,
ORDER_TABS,
} from './status'
describe('order status labels', () => {
it('uses the redesigned tab labels for known process statuses', () => {
expect(getStatusMeta('4').result).toBe('办理中')
expect(getStatusMeta('6').result).toBe('待处理')
expect(getStatusMeta('1').result).toBe('待办理')
expect(getStatusMeta('3').result).toBe('待办理')
expect(getStatusMeta('2').result).toBe('已完成')
expect(ORDER_TABS.map((tab) => tab.label)).toEqual(['全部', '办理中', '待处理', '待办理', '已完成'])
})
it('uses filled badge colors from the order card design', () => {
expect(getStatusMeta('4')).toMatchObject({ color: '#0261FC', bg: '#E5EFFE' })
expect(getStatusMeta('6')).toMatchObject({ color: '#FC6202', bg: '#FEEFE5' })
expect(getStatusMeta('1')).toMatchObject({ color: '#FF2E2E', bg: '#FFEAEA' })
expect(getStatusMeta('2')).toMatchObject({ color: '#07C160', bg: '#E6F8EF' })
})
})
describe('filterOrdersByTab', () => {
const orders = [
{ order_id: 'doing', process_status: '4' },
{ order_id: 'rejected', process_status: '6' },
{ order_id: 'waiting', process_status: '1' },
{ order_id: 'waiting-3', process_status: '3' },
{ order_id: 'done', process_status: '2' },
]
it('filters list orders into the five redesigned tabs', () => {
expect(filterOrdersByTab(orders, 'all').map((item) => item.order_id)).toEqual([
'doing', 'rejected', 'waiting', 'waiting-3', 'done',
])
expect(filterOrdersByTab(orders, 'doing').map((item) => item.order_id)).toEqual(['doing'])
expect(filterOrdersByTab(orders, 'rejected').map((item) => item.order_id)).toEqual(['rejected'])
expect(filterOrdersByTab(orders, 'waiting').map((item) => item.order_id)).toEqual(['waiting', 'waiting-3'])
expect(filterOrdersByTab(orders, 'done').map((item) => item.order_id)).toEqual(['done'])
})
})
describe('getOrderCardActions', () => {
it('shows complaint plus submit on 办理中 and 待处理', () => {
expect(getOrderCardActions({ process_status: '4', goods_type: 'map_label' })).toEqual({
complaint: true,
submit: true,
detail: false,
})
expect(getOrderCardActions({ process_status: '6', goods_type: 'map_label' })).toEqual({
complaint: true,
submit: true,
detail: false,
})
})
it('keeps submit available on 待办理', () => {
expect(getOrderCardActions({ process_status: '1', goods_type: 'map_label' })).toEqual({
complaint: true,
submit: true,
detail: false,
})
})
it('keeps submit available on completed orders and hides complaint', () => {
expect(getOrderCardActions({ process_status: '2', goods_type: 'map_label' })).toEqual({
complaint: false,
submit: true,
detail: true,
})
})
it('matches H5 actions for all three corp goods types', () => {
for (const goodsType of ['license_year', 'license_destory', 'credit_repair']) {
expect(getOrderCardActions({ process_status: '6', goods_type: goodsType })).toEqual({
complaint: true,
submit: true,
detail: false,
})
expect(getOrderCardActions({ process_status: '4', goods_type: goodsType })).toEqual({
complaint: false,
submit: true,
detail: true,
})
}
})
it('keeps submit available for every known and unknown status', () => {
for (const status of ['', '1', '2', '3', '4', '5', '6', '7', '8', 'unknown']) {
expect(getOrderCardActions({ process_status: status, goods_type: 'map_label' }).submit).toBe(true)
}
})
})

View File

@ -1,57 +0,0 @@
import { isCorpGoodsType } from './detail'
export const STATUS_CONFIG: Record<string, { color: string, bg: string, result: string }> = {
'4': { color: '#0261FC', bg: '#E5EFFE', result: '办理中' },
'1': { color: '#FF2E2E', bg: '#FFEAEA', result: '待办理' },
'3': { color: '#FF2E2E', bg: '#FFEAEA', result: '待办理' },
'2': { color: '#07C160', bg: '#E6F8EF', result: '已完成' },
'5': { color: '#07C160', bg: '#E6F8EF', result: '已完成' },
'7': { color: '#07C160', bg: '#E6F8EF', result: '已完成' },
'8': { color: '#07C160', bg: '#E6F8EF', result: '已完成' },
'6': { color: '#FC6202', bg: '#FEEFE5', result: '待处理' },
}
export const PAY_ICON: Record<string, string> = {
alipay: 'https://cdn.u8t.cn/frontend-static/corp-h5/static/images/alipay.png',
weixin: 'https://cdn.u8t.cn/frontend-static/corp-h5/static/images/weixin.png',
}
export const ORDER_TABS = [
{ key: 'all', label: '全部', statuses: null },
{ key: 'doing', label: '办理中', statuses: ['4'] },
{ key: 'rejected', label: '待处理', statuses: ['6'] },
{ key: 'waiting', label: '待办理', statuses: ['1', '3'] },
{ key: 'done', label: '已完成', statuses: ['2', '5', '7', '8'] },
] as const
export type OrderTabKey = (typeof ORDER_TABS)[number]['key']
const DONE_STATUSES = new Set(['2', '5', '7', '8'])
export function getStatusMeta (status?: string, name?: string) {
const config = STATUS_CONFIG[String(status || '')]
if (config) return { ...config }
return {
color: '#666666',
bg: '#F5F5F5',
result: name || '未知',
}
}
export function filterOrdersByTab<T extends Record<string, any>> (orders: T[] = [], tab: OrderTabKey) {
const current = ORDER_TABS.find((item) => item.key === tab)
if (!current || !current.statuses) return orders
const statuses = new Set<string>(current.statuses)
return orders.filter((order) => statuses.has(String(order.process_status || '')))
}
export function getOrderCardActions (order: { process_status?: string, goods_type?: string }) {
const status = String(order.process_status || '')
const corpGoods = isCorpGoodsType(order.goods_type)
const detail = corpGoods ? status !== '6' : DONE_STATUSES.has(status)
return {
complaint: !detail,
submit: true,
detail,
}
}

View File

@ -1,7 +0,0 @@
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)
})
}

View File

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

View File

@ -1,12 +0,0 @@
import ComplaintPage, { type ComplaintServices } from '@/pages/complaint/index'
import { a5Request, getA5Draft, uploadA5Image } from '@/lib/a5/api'
const services: ComplaintServices = {
getDraft: () => getA5Draft()?.order || null,
upload: (path, onProgress) => {
const draft = getA5Draft()
return uploadA5Image(path, draft?.linkId || '', String(draft?.order.order_id || ''), 'process', undefined, onProgress)
},
submit: (data) => a5Request('/api/user/feedback', { method: 'POST', data }),
}
export default function A5Complaint () { return <ComplaintPage services={services} /> }

View File

@ -1,73 +0,0 @@
import { Image, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useEffect, useState } from 'react'
import { getA5Files } from '@/lib/a5/api'
import type { A5File } from '@/lib/a5/data'
import { LOCAL_IMAGES } from '@/lib/assets'
import type { OrderLike } from '@/lib/order'
const PLATFORMS = [
{ scene: 'order_process_gd', name: '高德地图' },
{ scene: 'order_process_bd', name: '百度地图' },
{ scene: 'order_process_tx', name: '腾讯地图' },
]
export default function A5Detail ({ order, linkId, onCancel }: { order: OrderLike, linkId: string, onCancel: () => void }) {
return (
<View className='detail-mask' onClick={onCancel}>
<View className='detail-drawer' onClick={(event) => event.stopPropagation()}>
<View className='detail-dialog__head'>
<Text className='detail-dialog__title'></Text>
<View className='detail-dialog__close' onClick={onCancel}><Image className='detail-dialog__close-img' src={LOCAL_IMAGES.close} /></View>
</View>
<View className='detail-dialog__body'>
<Text className='detail-dialog__section-title'></Text>
{order.process_status === '2' ? PLATFORMS.map((platform) => (
<PlatformFiles key={`${order.order_id}-${platform.scene}`} orderId={String(order.order_id)} linkId={linkId} {...platform} />
)) : <Text className='detail-dialog__empty'></Text>}
</View>
</View>
</View>
)
}
function PlatformFiles ({ orderId, linkId, scene, name }: { orderId: string, linkId: string, scene: string, name: string }) {
const [files, setFiles] = useState<A5File[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let active = true
setLoading(true)
setError('')
void getA5Files(linkId, orderId, scene).then((result) => {
if (active) setFiles(result)
}).catch((reason) => {
if (active) setError(reason instanceof Error ? reason.message : '截图加载失败')
}).finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [linkId, orderId, scene, attempt])
const isDocument = (file: A5File) => file.fileType === 'document' || /\.pdf(?:\?|$)/i.test(file.url)
const images = files.filter((file) => !isDocument(file))
return (
<View className='detail-dialog__section'>
<Text className='detail-dialog__section-title'>{name}</Text>
{loading ? <Text>...</Text> : error ? (
<View onClick={() => setAttempt((value) => value + 1)}><Text>{error}</Text></View>
) : !files.length ? <Text className='detail-dialog__empty'></Text> : <>
{images.map((file) => <Image key={file.id || file.url} className='detail-dialog__shot' src={file.url} mode='widthFix' onClick={() => Taro.previewImage({ current: file.url, urls: images.map((image) => image.url) })} />)}
{files.filter(isDocument).map((file) => <View key={file.id || file.url} className='detail-dialog__pdf' onClick={() => void openDocument(file)}><Text>{file.name || '报告文件'}</Text></View>)}
</>}
</View>
)
}
async function openDocument (file: A5File) {
try {
if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) {
await Taro.setClipboardData({ data: file.url })
return
}
Taro.showLoading({ title: '打开中...' })
const response = await Taro.downloadFile({ url: file.url })
if (response.statusCode !== 200) throw new Error('下载失败')
await Taro.openDocument({ filePath: response.tempFilePath, showMenu: true })
} catch { Taro.showToast({ title: '报告打开失败', icon: 'none' }) } finally { Taro.hideLoading() }
}

View File

@ -1 +0,0 @@
export default definePageConfig({ navigationStyle: 'custom', backgroundColor: '#F6F8FC' })

View File

@ -1,3 +0,0 @@
import Index from '@/pages/index/index'
export default function A5Page () { return <Index a5 /> }

View File

@ -1,6 +0,0 @@
export default definePageConfig({
navigationBarTitleText: '重新提交',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#F7F8FA',
})

View File

@ -1,33 +0,0 @@
.resubmit-page--a5 {
.resubmit-card { width: 100%; }
.resubmit__control-value, .resubmit__error { overflow-wrap: anywhere; }
}
.a5-doc {
margin-bottom: 28px;
&:last-child { margin-bottom: 0; }
&__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
&__header .resubmit__label { margin-bottom: 0; }
&__example { flex-shrink: 0; font-size: 24px; color: #165dff; }
&__loading { display: block; margin-bottom: 16px; font-size: 24px; color: #86909c; }
&__grid { display: flex; gap: 20px; }
&__item { flex: 1; min-width: 0; max-width: calc((100% - 40px) / 3); text-align: center; }
&__media { width: 100%; aspect-ratio: 1; box-sizing: border-box; background: #fff; border: 2px solid #e5e6eb; border-radius: 16px; overflow: hidden; position: relative; display: flex; align-items: center; justify-content: center; }
&__image { width: 100%; height: 100%; }
&__upload { width: 100%; height: 100%; margin: 0; padding: 12px 0; display: flex; flex-direction: column; justify-content: center; align-items: center; background: transparent; font-size: 22px; line-height: 1.5; color: #c9cdd4; }
&__upload[disabled] { background: transparent; color: #c9cdd4; }
&__upload::after, &__remove::after { border: 0; }
&__camera { width: 56px; height: 56px; margin-bottom: 8px; }
&__remove { position: absolute; top: 0; right: 0; width: 48px; height: 48px; padding: 0; line-height: 44px; border-radius: 0 16px 0 16px; background: rgba(29, 33, 41, .55); color: #fff; font-size: 32px; }
&__caption { display: block; font-size: 24px; color: #1d2129; margin-top: 12px; }
&__progress { position: absolute; bottom: 0; left: 0; right: 0; background: #165dff; color: white; text-align: center; font-size: 22px; }
}
.a5-example {
position: fixed; inset: 0; z-index: 1200; background: rgba(0, 0, 0, .65); display: flex; align-items: center; justify-content: center; padding: 28px; box-sizing: border-box;
&__dialog { width: 100%; max-height: 85vh; overflow-y: auto; padding: 28px; background: white; border-radius: 16px; box-sizing: border-box; }
&__title { display: block; text-align: center; font-size: 32px; font-weight: 600; margin: 8px 0 28px; }
&__image { width: 100%; height: 180px; }
&__subtitle { display: block; font-size: 28px; font-weight: 600; margin: 28px 0 16px; }
&__requirements { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; font-size: 24px; color: #666; }
.a5-doc__item { max-width: none; }
.resubmit__submit { margin-top: 28px; }
}

View File

@ -1,141 +0,0 @@
import { Button, Text, View } from '@tarojs/components'
import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { useRef, useState } from 'react'
import { a5Request, deleteA5File, getA5Draft, getA5Files, uploadA5Image, type A5Draft } from '@/lib/a5/api'
import { buildA5Update, STOREFRONT_LOCS, storefrontFromFiles } from '@/lib/a5/data'
import { formatMapCoordinate, getInitialPhone2, getOrderAddressName, getOrderExtraImages, getOrderMapAddress, parseCoordinate, parseStorefrontSlots, type ResubmitValues, type StorefrontSlot } from '@/lib/order'
import { isValidPhone } from '@/lib/phone'
import { consumeMapPickerResult } from '@/pages/map/picker'
import PrivacyConsent from '@/components/privacy-consent'
import { chooseA5Image, imageErrorMessage } from '@/lib/a5/image'
import { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent'
import { MapResubmitFields, ResubmitNotes } from '@/pages/resubmit/fields'
import '@/pages/resubmit/index.scss'
import Documents from './documents'
import './index.scss'
export default function A5Resubmit () {
const [draft, setDraft] = useState<A5Draft | null>(null)
const [values, setValues] = useState<ResubmitValues>({})
const [slots, setSlots] = useState<StorefrontSlot[]>(Array(6).fill(null))
const [loading, setLoading] = useState(true)
const [filesReady, setFilesReady] = useState(false)
const [busy, setBusy] = useState<number | null>(null)
const [progress, setProgress] = useState(0)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [loadError, setLoadError] = useState('')
const [agreed, setAgreed] = useState(hasPrivacyConsent)
const lock = useRef(false)
const returningMap = useRef(false)
async function loadFiles (current: A5Draft) {
setLoading(true)
setFilesReady(false)
setLoadError('')
try {
const [storefront, license, identity] = await Promise.all(['order_biz2', 'order_biz1', 'order_biz3'].map((scene) => getA5Files(current.linkId, String(current.order.order_id), scene)))
setSlots((previous) => [
...(storefront.length ? storefrontFromFiles(storefront) : previous.slice(0, 3)),
...(license.length ? [license[license.length - 1]] : previous.slice(3, 4)),
...(identity.length ? [identity.slice(-2)[0] || null, identity.slice(-2)[1] || null] : previous.slice(4, 6)),
])
setFilesReady(true)
} catch (reason) {
setLoadError(imageErrorMessage(reason, '图片加载失败'))
} finally { setLoading(false) }
}
useLoad(() => {
const current = getA5Draft()
if (!current) {
setLoading(false)
setError('订单不存在,请返回查询页重新进入')
return
}
consumeMapPickerResult()
setDraft(current)
const order = current.order
setValues({ entity_name: order.entity_name || '', entity_phone: order.entity_phone || '', entity_phone2: getInitialPhone2(order), entity_address_name: getOrderAddressName(order), entity_address: getOrderMapAddress(order) })
const license = getOrderExtraImages(order, ['entity_business_license_image', 'business_license_image'])
const identity = getOrderExtraImages(order, ['entity_identity_card_image', 'identity_card_image'])
setSlots([...parseStorefrontSlots(order), license[0] || null, identity[0] || null, identity[1] || null])
void loadFiles(current)
})
useDidShow(() => {
if (!returningMap.current) return
returningMap.current = false
const point = consumeMapPickerResult()
if (point) setValues((current) => ({ ...current, entity_address: formatMapCoordinate(point), entity_address_name: point.address || point.name || current.entity_address_name }))
})
const disabled = loading || busy !== null || submitting
const update = (field: keyof ResubmitValues, value: string) => setValues((current) => ({ ...current, [field]: value }))
async function upload (index: number) {
if (!draft || disabled || !filesReady || lock.current) return
lock.current = true
try {
setError('')
const path = await chooseA5Image()
if (!path) return
setBusy(index)
setProgress(0)
setError('')
const uploaded = await uploadA5Image(path, draft.linkId, String(draft.order.order_id), index < 3 ? 'order_biz2' : index === 3 ? 'order_biz1' : 'order_biz3', index < 3 ? { loc: STOREFRONT_LOCS[index] } : undefined, setProgress)
setSlots((current) => current.map((slot, position) => position === index ? { ...uploaded, preview: path } : slot))
} catch (reason) {
setError(imageErrorMessage(reason))
} finally { setBusy(null); lock.current = false }
}
async function remove (index: number) {
if (disabled || !filesReady || lock.current) return
lock.current = true
try {
const confirmation = await Taro.showModal({ title: '删除照片', content: '确定删除这张照片?' })
if (!confirmation.confirm) return
setBusy(index)
setProgress(0)
if (slots[index]?.id) await deleteA5File(slots[index]!.id)
setSlots((current) => current.map((slot, position) => position === index ? null : slot))
} catch (reason) { setError(reason instanceof Error ? reason.message : '删除失败') } finally { setBusy(null); lock.current = false }
}
async function submit () {
if (!draft || disabled || !filesReady || lock.current) return
const invalid = !agreed ? '请先阅读并同意协议'
: !values.entity_name?.trim() ? '请输入门店名称'
: !values.entity_address_name?.trim() ? '请输入实际经营地址'
: !values.entity_address ? '请选择门店位置'
: !isValidPhone(values.entity_phone || '') ? '请填写正确的门店电话'
: !isValidPhone(values.entity_phone2 || '') ? '请填写正确的联系人电话'
: slots.slice(0, 3).some((slot) => !slot?.id) ? '请上传门头正面、左面和右面照片' : ''
if (invalid) { Taro.showToast({ title: invalid, icon: 'none' }); return }
lock.current = true
setSubmitting(true)
try {
await a5Request('/h5/order', { method: 'PUT', data: buildA5Update(draft.order, values, slots) })
Taro.showToast({ title: '重新提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500)
} catch (reason) { setError(reason instanceof Error ? reason.message : '重新提交失败') } finally { setSubmitting(false); lock.current = false }
}
function chooseMap () {
const point = parseCoordinate(values.entity_address)
consumeMapPickerResult()
returningMap.current = true
void Taro.navigateTo({ url: `/pages/map/index?name=${encodeURIComponent(values.entity_address_name || '')}${point ? `&lng=${point.lng}&lat=${point.lat}` : ''}` })
}
return <View className='resubmit-page resubmit-page--a5'>
{draft?.order.reject_reason ? <View className='resubmit-page__reject'>{draft.order.reject_reason}</View> : null}
<View className='resubmit-page__body resubmit-page__body--consent'>
<View className='resubmit-card'>
<MapResubmitFields values={values} onChange={update} onChooseMap={chooseMap} />
{loading ? <Text className='a5-doc__loading'>...</Text> : null}
<Documents slots={slots} busy={busy} progress={progress} disabled={disabled || !filesReady} onUpload={(index) => void upload(index)} onRemove={(index) => void remove(index)} />
</View>
<ResubmitNotes />
{loadError ? <View className='resubmit__error'>{loadError}{draft ? <Text onClick={() => !disabled && void loadFiles(draft)}> </Text> : null}</View> : null}
{error ? <View className='resubmit__error'>{error}</View> : null}
</View>
<View className='resubmit-page__footer resubmit-page__footer--consent'>
<PrivacyConsent className='resubmit__privacy' agreed={agreed} onChange={(value) => { setAgreed(value); savePrivacyConsent(value) }} />
<Button className='resubmit__submit' loading={submitting} disabled={disabled || !filesReady || !draft} onClick={() => void submit()}></Button>
</View>
</View>
}

View File

@ -1,10 +1,9 @@
import { Button, Image, Input, Text, Textarea, View } from '@tarojs/components' import { Button, Image, Input, Text, Textarea, View } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro' import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react' import { useState } from 'react'
import { report, uploadImage } from '@/api/user' import { a5Request, getA5Draft, uploadA5Image } from '@/lib/a5/api'
import { LOCAL_IMAGES } from '@/lib/assets' import { LOCAL_IMAGES } from '@/lib/assets'
import { buildFeedbackPayload, COMPLAINT_TYPES, getFeedbackImageSrc, type ComplaintType } from '@/lib/feedback' import { buildFeedbackPayload, COMPLAINT_TYPES, getFeedbackImageSrc, type ComplaintType } from '@/lib/feedback'
import { getOrderDraft } from '@/lib/orderDraft'
import { getOrderDisplayName, type OrderLike, type UploadedImage } from '@/lib/order' import { getOrderDisplayName, type OrderLike, type UploadedImage } from '@/lib/order'
import './index.scss' import './index.scss'
@ -31,13 +30,7 @@ const TYPE_ICONS: Record<ComplaintType, { idle: string, active: string }> = {
}, },
} }
export type ComplaintServices = { export default function ComplaintPage () {
getDraft: () => OrderLike | null
upload: typeof uploadImage
submit: typeof report
}
export default function ComplaintPage ({ services }: { services?: ComplaintServices }) {
const [order, setOrder] = useState<OrderLike | null>(null) const [order, setOrder] = useState<OrderLike | null>(null)
const [type, setType] = useState('') const [type, setType] = useState('')
const [content, setContent] = useState('') const [content, setContent] = useState('')
@ -47,7 +40,7 @@ export default function ComplaintPage ({ services }: { services?: ComplaintServi
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
useLoad(() => { useLoad(() => {
const draft = services ? services.getDraft() : getOrderDraft() const draft = getA5Draft()?.order
if (!draft) { if (!draft) {
Taro.showToast({ title: '订单不存在', icon: 'none' }) Taro.showToast({ title: '订单不存在', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 400) setTimeout(() => Taro.navigateBack(), 400)
@ -73,7 +66,8 @@ export default function ComplaintPage ({ services }: { services?: ComplaintServi
].slice(0, 5)) ].slice(0, 5))
setUploading(true) setUploading(true)
try { try {
const result = await (services?.upload || uploadImage)(filePath) const draft = getA5Draft()
const result = await uploadA5Image(filePath, draft?.linkId || '', String(draft?.order.order_id || ''), 'process')
setImages((current) => current.map((image) => ( setImages((current) => current.map((image) => (
image.preview === filePath image.preview === filePath
? { id: result.id, url: result.url, preview: filePath } ? { id: result.id, url: result.url, preview: filePath }
@ -107,13 +101,13 @@ export default function ComplaintPage ({ services }: { services?: ComplaintServi
setSubmitting(true) setSubmitting(true)
try { try {
await (services?.submit || report)(buildFeedbackPayload({ await a5Request('/api/user/feedback', { method: 'POST', data: buildFeedbackPayload({
contact: contact.trim(), contact: contact.trim(),
content: content.trim(), content: content.trim(),
images: images.map((image) => image.url), images: images.map((image) => image.url),
type, type,
order, order,
})) }) })
Taro.showToast({ title: '提交成功', icon: 'success' }) Taro.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500) setTimeout(() => Taro.navigateBack(), 500)
} catch (error) { } catch (error) {
@ -132,8 +126,8 @@ export default function ComplaintPage ({ services }: { services?: ComplaintServi
</View> </View>
<View className='complaint-hero__meta'> <View className='complaint-hero__meta'>
<View className='complaint-hero__meta-row'> <View className='complaint-hero__meta-row'>
<Text className='complaint-hero__meta-label'>{services ? '订单编号' : '交易ID'}</Text> <Text className='complaint-hero__meta-label'></Text>
<Text className='complaint-hero__meta-value'>{(services ? order?.order_id : order?.out_trade_no) || '-'}</Text> <Text className='complaint-hero__meta-value'>{order?.order_id || '-'}</Text>
</View> </View>
<View className='complaint-hero__meta-row'> <View className='complaint-hero__meta-row'>
<Text className='complaint-hero__meta-label'></Text> <Text className='complaint-hero__meta-label'></Text>

View File

@ -1,202 +1,73 @@
import { Image, Text, View } from '@tarojs/components' import { Image, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { getA5Files } from '@/lib/a5/api'
import type { A5File } from '@/lib/a5/data'
import { LOCAL_IMAGES } from '@/lib/assets' import { LOCAL_IMAGES } from '@/lib/assets'
import { RECEIPT_STAMP_DONE, RECEIPT_STAMP_PENDING } from '@/lib/cdn'
import {
getReceiptMeta,
numberToUpperCase,
shouldShowReceipt,
splitOrderFiles,
type OrderFile,
} from '@/lib/detail'
import type { OrderLike } from '@/lib/order' import type { OrderLike } from '@/lib/order'
const DRAWER_ANIMATION_MS = 300 const PLATFORMS = [
{ scene: 'order_process_gd', name: '高德地图' },
type Props = { { scene: 'order_process_bd', name: '百度地图' },
order: OrderLike { scene: 'order_process_tx', name: '腾讯地图' },
onCancel: () => void ]
} export default function A5Detail ({ order, linkId, onCancel }: { order: OrderLike, linkId: string, onCancel: () => void }) {
export default function DetailDrawer ({ order, onCancel }: Props) {
const [leaving, setLeaving] = useState(false)
const onCancelRef = useRef(onCancel)
onCancelRef.current = onCancel
const showReceipt = shouldShowReceipt(order.goods_type)
const receipt = getReceiptMeta(order.goods_type, order.goods_name)
const files = splitOrderFiles(order.files)
const empty = !showReceipt && files.images.length === 0 && files.pdfs.length === 0
const imageUrls = files.images.map((file) => String(file.url))
const handleClose = () => {
if (leaving) return
setLeaving(true)
}
useEffect(() => {
if (!leaving) return
const timer = setTimeout(() => onCancelRef.current(), DRAWER_ANIMATION_MS)
return () => clearTimeout(timer)
}, [leaving])
return ( return (
<View <View className='detail-mask' onClick={onCancel}>
className={`detail-mask${leaving ? ' detail-mask--leave' : ''}`} <View className='detail-drawer' onClick={(event) => event.stopPropagation()}>
onClick={handleClose}
>
<View
className={`detail-drawer${leaving ? ' detail-drawer--leave' : ''}`}
onClick={(event) => event.stopPropagation()}
>
<View className='detail-drawer__handle' />
<View className='detail-dialog__head'> <View className='detail-dialog__head'>
<Text className='detail-dialog__title'></Text> <Text className='detail-dialog__title'></Text>
<View className='detail-dialog__close' onClick={handleClose}> <View className='detail-dialog__close' onClick={onCancel}><Image className='detail-dialog__close-img' src={LOCAL_IMAGES.close} /></View>
<Image className='detail-dialog__close-img' src={LOCAL_IMAGES.close} />
</View>
</View> </View>
<View className='detail-dialog__body'> <View className='detail-dialog__body'>
{showReceipt ? <ReceiptCard order={order} title={receipt.title} description={receipt.description} /> : null} <Text className='detail-dialog__section-title'></Text>
{order.process_status === '2' ? PLATFORMS.map((platform) => (
{files.images.length ? ( <PlatformFiles key={`${order.order_id}-${platform.scene}`} orderId={String(order.order_id)} linkId={linkId} {...platform} />
<View className='detail-dialog__section'> )) : <Text className='detail-dialog__empty'></Text>}
<Text className='detail-dialog__section-title'></Text>
{files.images.map((file, index) => (
<Image
key={file.id || file.url || index}
className='detail-dialog__shot'
src={String(file.url)}
mode='widthFix'
onClick={() => previewImages(imageUrls, String(file.url))}
/>
))}
</View>
) : null}
{files.pdfs.length ? (
<View className='detail-dialog__section'>
<Text className='detail-dialog__section-title'></Text>
{files.pdfs.map((file, index) => (
<View
key={file.id || file.url || index}
className='detail-dialog__pdf'
onClick={() => void openPdf(file)}
>
<Text className='detail-dialog__pdf-name'>{files.pdfs.length > 1 ? index + 1 : ''}</Text>
</View>
))}
</View>
) : null}
{empty ? (
<Text className='detail-dialog__empty'></Text>
) : null}
</View> </View>
</View> </View>
</View> </View>
) )
} }
function PlatformFiles ({ orderId, linkId, scene, name }: { orderId: string, linkId: string, scene: string, name: string }) {
function ReceiptCard ({ const [files, setFiles] = useState<A5File[]>([])
order, const [loading, setLoading] = useState(true)
title, const [error, setError] = useState('')
description, const [attempt, setAttempt] = useState(0)
}: { useEffect(() => {
order: OrderLike let active = true
title: string setLoading(true)
description: string setError('')
}) { void getA5Files(linkId, orderId, scene).then((result) => {
const stamp = order.process_status === '2' ? RECEIPT_STAMP_DONE : RECEIPT_STAMP_PENDING if (active) setFiles(result)
const remarkLines = description.split('\n').filter(Boolean) }).catch((reason) => {
if (active) setError(reason instanceof Error ? reason.message : '截图加载失败')
}).finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [linkId, orderId, scene, attempt])
const isDocument = (file: A5File) => file.fileType === 'document' || /\.pdf(?:\?|$)/i.test(file.url)
const images = files.filter((file) => !isDocument(file))
return ( return (
<View className='detail-dialog__section'> <View className='detail-dialog__section'>
<Text className='detail-dialog__section-title'></Text> <Text className='detail-dialog__section-title'>{name}</Text>
<View className='detail-receipt'> {loading ? <Text>...</Text> : error ? (
<Text className='detail-receipt__title'>{title}</Text> <View onClick={() => setAttempt((value) => value + 1)}><Text>{error}</Text></View>
<View className='detail-receipt__rule' /> ) : !files.length ? <Text className='detail-dialog__empty'></Text> : <>
<View className='detail-receipt__rule detail-receipt__rule--thin' /> {images.map((file) => <Image key={file.id || file.url} className='detail-dialog__shot' src={file.url} mode='widthFix' onClick={() => Taro.previewImage({ current: file.url, urls: images.map((image) => image.url) })} />)}
{files.filter(isDocument).map((file) => <View key={file.id || file.url} className='detail-dialog__pdf' onClick={() => void openDocument(file)}><Text>{file.name || '报告文件'}</Text></View>)}
<View className='detail-receipt__box'> </>}
<View className='detail-receipt__inner'>
<View className='detail-receipt__fields'>
<Image className='detail-receipt__stamp' src={stamp} />
<ReceiptLine label='名称:' value={order.entity_name || ''} />
<ReceiptLine
label='法人:'
value={`${order.entity_legal || ''}${order.entity_phone ? ` ${order.entity_phone}` : ''}`}
/>
<ReceiptLine label='统一社会信用代码:' value={order.entity_id || ''} />
<ReceiptLine label='已受理时间:' value={order.pay_time || ''} />
<ReceiptLine label='申报内容:' value={order.goods_name || ''} />
<ReceiptLine label='数量:' value='1' />
<ReceiptLine label='金额(小写):' value={formatAmount(order.total_fee)} />
<ReceiptLine label='金额(大写):' value={numberToUpperCase(order.total_fee)} />
<ReceiptLine label='支付时间:' value={order.pay_time || ''} />
<ReceiptLine label='订单号码:' value={order.out_trade_no || ''} />
</View>
<View className='detail-receipt__remark'>
<View className='detail-receipt__remark-label'>
<Text className='detail-receipt__remark-label-text'></Text>
</View>
<View className='detail-receipt__remark-body'>
{remarkLines.map((line, index) => (
<Text key={`${index}-${line}`} className='detail-receipt__remark-line'>{line}</Text>
))}
</View>
</View>
</View>
</View>
</View>
</View> </View>
) )
} }
async function openDocument (file: A5File) {
function formatAmount (amount: unknown) {
if (amount === '' || amount === undefined || amount === null) return ''
const value = Number(amount)
return Number.isFinite(value) ? `${value.toFixed(2)}` : `${String(amount)}`
}
function ReceiptLine ({ label, value }: { label: string, value: string }) {
return (
<View className='detail-receipt__row'>
<Text className='detail-receipt__label'>{label}</Text>
<Text className='detail-receipt__value'>{value}</Text>
</View>
)
}
function previewImages (urls: string[], current: string) {
if (!urls.length) return
Taro.previewImage({ current, urls })
}
async function openPdf (file: OrderFile) {
const url = String(file.url || '')
if (!url) return
try { try {
Taro.showLoading({ title: '打开中...', mask: true }) if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) {
if (Taro.getEnv() === Taro.ENV_TYPE.WEAPP) { await Taro.setClipboardData({ data: file.url })
const downloaded = await Taro.downloadFile({ url })
if (downloaded.statusCode !== 200 || !downloaded.tempFilePath) {
throw new Error('download failed')
}
await Taro.openDocument({
filePath: downloaded.tempFilePath,
fileType: 'pdf',
showMenu: true,
})
return return
} }
Taro.setClipboardData({ data: url }) Taro.showLoading({ title: '打开中...' })
} catch { const response = await Taro.downloadFile({ url: file.url })
Taro.showToast({ title: '报告打开失败', icon: 'none' }) if (response.statusCode !== 200) throw new Error('下载失败')
} finally { await Taro.openDocument({ filePath: response.tempFilePath, showMenu: true })
Taro.hideLoading() } catch { Taro.showToast({ title: '报告打开失败', icon: 'none' }) } finally { Taro.hideLoading() }
}
} }

View File

@ -1,7 +1 @@
export default definePageConfig({ export default definePageConfig({ navigationStyle: 'custom', backgroundColor: '#F6F8FC' })
navigationStyle: 'custom',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#F6F8FC',
backgroundColor: '#F6F8FC',
backgroundColorTop: '#F6F8FC',
})

View File

@ -415,112 +415,6 @@
text-align: center; text-align: center;
} }
.detail-receipt {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 8px;
}
.detail-receipt__title {
color: #1a1a1a;
font-size: 36px;
line-height: 52px;
text-align: center;
}
.detail-receipt__rule {
width: 280px;
height: 4px;
margin-top: 8px;
background: #1a1a1a;
}
.detail-receipt__rule--thin {
height: 2px;
margin-top: 4px;
}
.detail-receipt__box {
box-sizing: border-box;
width: 100%;
margin-top: 24px;
padding: 6px;
border: 2px solid #1a1a1a;
}
.detail-receipt__inner {
border: 2px solid #1a1a1a;
}
.detail-receipt__fields {
position: relative;
padding: 28px 20px 20px;
}
.detail-receipt__stamp {
position: absolute;
top: -16px;
right: 8px;
width: 160px;
height: 160px;
}
.detail-receipt__row {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
}
.detail-receipt__label {
flex-shrink: 0;
color: #1a1a1a;
font-size: 26px;
line-height: 36px;
}
.detail-receipt__value {
flex: 1;
min-width: 0;
color: #1a1a1a;
font-size: 24px;
line-height: 36px;
word-break: break-all;
}
.detail-receipt__remark {
display: flex;
border-top: 2px solid #1a1a1a;
}
.detail-receipt__remark-label {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
border-right: 2px solid #1a1a1a;
}
.detail-receipt__remark-label-text {
color: #1a1a1a;
font-size: 22px;
writing-mode: vertical-lr;
letter-spacing: 4px;
}
.detail-receipt__remark-body {
flex: 1;
padding: 16px 16px 20px;
}
.detail-receipt__remark-line {
display: block;
color: #666;
font-size: 22px;
line-height: 32px;
white-space: pre-wrap;
}
.contact-fab { .contact-fab {
position: fixed; position: fixed;
right: 32px; right: 32px;

View File

@ -3,29 +3,18 @@ import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { getA5Config, getA5Orders, setA5Draft } from '@/lib/a5/api' import { getA5Config, getA5Orders, setA5Draft } from '@/lib/a5/api'
import { A5_STATUSES, A5_TABS, parseA5Launch } from '@/lib/a5/data' import { A5_STATUSES, A5_TABS, parseA5Launch } from '@/lib/a5/data'
import A5Detail from '@/pages/a5/detail' import { clearQueryPhone, getQueryPhone, saveQueryPhone } from '@/lib/a5/queryPhone'
import { getOrdersByPhone } from '@/api/pay' import A5Detail from './detail'
import { LOCAL_IMAGES } from '@/lib/assets' import { LOCAL_IMAGES } from '@/lib/assets'
import { bootstrapSession } from '@/lib/bootstrap'
import { COPY_ICON, PAGE_TITLE } from '@/lib/cdn' import { COPY_ICON, PAGE_TITLE } from '@/lib/cdn'
import { getNavMetrics } from '@/lib/nav' import { getNavMetrics } from '@/lib/nav'
import { pickServiceContact, type ServiceContact } from '@/lib/contact' import { pickServiceContact, type ServiceContact } from '@/lib/contact'
import { setOrderDraft } from '@/lib/orderDraft' import { getOrderDisplayName, type OrderLike } from '@/lib/order'
import { filterOrdersForProduct, getOrderDisplayName, type OrderLike } from '@/lib/order'
import { isValidPhone } from '@/lib/phone' import { isValidPhone } from '@/lib/phone'
import { getSession } from '@/lib/session'
import {
filterOrdersByTab,
getOrderCardActions,
getStatusMeta,
ORDER_TABS,
type OrderTabKey,
} from '@/lib/status'
import ContactModal, { ContactFab } from './contact' import ContactModal, { ContactFab } from './contact'
import DetailDrawer from './detail'
import './index.scss' import './index.scss'
export default function Index ({ a5 = false }: { a5?: boolean }) { export default function Index () {
const linkIdRef = useRef('') const linkIdRef = useRef('')
const queryIdRef = useRef(0) const queryIdRef = useRef(0)
const nav = useMemo(() => getNavMetrics(), []) const nav = useMemo(() => getNavMetrics(), [])
@ -33,7 +22,7 @@ export default function Index ({ a5 = false }: { a5?: boolean }) {
const [inputPhone, setInputPhone] = useState('') const [inputPhone, setInputPhone] = useState('')
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [searched, setSearched] = useState(false) const [searched, setSearched] = useState(false)
const [activeTab, setActiveTab] = useState<OrderTabKey>('all') const [activeTab, setActiveTab] = useState<(typeof A5_TABS)[number]['key']>('all')
const [orders, setOrders] = useState<OrderLike[]>([]) const [orders, setOrders] = useState<OrderLike[]>([])
const [detailOrder, setDetailOrder] = useState<OrderLike | null>(null) const [detailOrder, setDetailOrder] = useState<OrderLike | null>(null)
const [showContact, setShowContact] = useState(false) const [showContact, setShowContact] = useState(false)
@ -45,24 +34,15 @@ export default function Index ({ a5 = false }: { a5?: boolean }) {
useLoad(async (options) => { useLoad(async (options) => {
try { try {
if (a5) { const launch = parseA5Launch(options || {})
const launch = parseA5Launch(options || {}) linkIdRef.current = launch.linkId
linkIdRef.current = launch.linkId const phone = getQueryPhone(launch.phone)
setInputPhone(launch.phone) setInputPhone(phone)
setReady(true)
void getA5Config(launch.linkId).then((config) => setContact(pickServiceContact(config))).catch(() => {
Taro.showToast({ title: '客服配置加载失败', icon: 'none' })
})
if (launch.phone) await queryOrders(launch.phone)
return
}
const session = await bootstrapSession(options || {})
setInputPhone(session.phone)
setContact(pickServiceContact(session.config))
setReady(true) setReady(true)
if (session.phone) { void getA5Config(launch.linkId).then((config) => setContact(pickServiceContact(config))).catch(() => {
await queryOrders(session.phone) Taro.showToast({ title: '客服配置加载失败', icon: 'none' })
} })
if (phone) await queryOrders(phone)
} catch (error) { } catch (error) {
Taro.showToast({ Taro.showToast({
title: error instanceof Error ? error.message : '初始化失败', title: error instanceof Error ? error.message : '初始化失败',
@ -81,12 +61,13 @@ export default function Index ({ a5 = false }: { a5?: boolean }) {
return return
} }
saveQueryPhone(phone)
const queryId = ++queryIdRef.current const queryId = ++queryIdRef.current
setChecking(true) setChecking(true)
try { try {
const res = a5 ? await getA5Orders(phone) : await getOrdersByPhone(phone) const res = await getA5Orders(phone)
if (queryId !== queryIdRef.current) return if (queryId !== queryIdRef.current) return
const nextOrders = a5 ? res.data : filterOrdersForProduct(getSession().goodsType, res.data || []) const nextOrders = res.data
setOrders(nextOrders) setOrders(nextOrders)
setSearched(true) setSearched(true)
} catch { } catch {
@ -102,22 +83,15 @@ export default function Index ({ a5 = false }: { a5?: boolean }) {
} }
}) })
const tabs = a5 ? A5_TABS : ORDER_TABS const tabs = A5_TABS
const a5Statuses: readonly string[] = A5_TABS.find((tab) => tab.key === activeTab)?.statuses || [] const a5Statuses: readonly string[] = A5_TABS.find((tab) => tab.key === activeTab)?.statuses || []
const visibleOrders = a5 const visibleOrders = orders.filter((order) => !a5Statuses.length || a5Statuses.includes(order.process_status))
? orders.filter((order) => !a5Statuses.length || a5Statuses.includes(order.process_status))
: filterOrdersByTab(orders, activeTab)
const emptyText = searched && orders.length === 0 const emptyText = searched && orders.length === 0
? '该手机号未查询到订单,请联系客服处理' ? '该手机号未查询到订单,请联系客服处理'
: '暂无订单列表' : '暂无订单列表'
const openOrderPage = (path: string, order: OrderLike) => { const openOrderPage = (path: string, order: OrderLike) => {
if (a5) { setA5Draft({ order, linkId: linkIdRef.current })
setA5Draft({ order, linkId: linkIdRef.current })
Taro.navigateTo({ url: path.replace('/pages/', '/pages/a5/') })
return
}
setOrderDraft(order)
Taro.navigateTo({ url: path }) Taro.navigateTo({ url: path })
} }
@ -144,7 +118,10 @@ export default function Index ({ a5 = false }: { a5?: boolean }) {
<Image <Image
className='home-search__clear' className='home-search__clear'
src={LOCAL_IMAGES.clear} src={LOCAL_IMAGES.clear}
onClick={() => setInputPhone('')} onClick={() => {
setInputPhone('')
clearQueryPhone()
}}
/> />
) : null} ) : null}
</View> </View>
@ -176,10 +153,9 @@ export default function Index ({ a5 = false }: { a5?: boolean }) {
{visibleOrders.map((order, index) => ( {visibleOrders.map((order, index) => (
<OrderCard <OrderCard
key={order.order_id || order.transaction_id || index} key={order.order_id || order.transaction_id || index}
a5={a5}
order={order} order={order}
onCopy={() => { onCopy={() => {
const number = a5 ? order.order_id : order.transaction_id const number = order.order_id
if (!number) return if (!number) return
Taro.setClipboardData({ data: String(number) }) Taro.setClipboardData({ data: String(number) })
}} }}
@ -199,8 +175,7 @@ export default function Index ({ a5 = false }: { a5?: boolean }) {
<ContactFab onClick={() => setShowContact(true)} /> <ContactFab onClick={() => setShowContact(true)} />
{detailOrder ? ( {detailOrder ? (
a5 ? <A5Detail order={detailOrder} linkId={linkIdRef.current} onCancel={() => setDetailOrder(null)} /> <A5Detail order={detailOrder} linkId={linkIdRef.current} onCancel={() => setDetailOrder(null)} />
: <DetailDrawer order={detailOrder} onCancel={() => setDetailOrder(null)} />
) : null} ) : null}
<ContactModal <ContactModal
@ -213,14 +188,12 @@ export default function Index ({ a5 = false }: { a5?: boolean }) {
} }
function OrderCard ({ function OrderCard ({
a5,
order, order,
onCopy, onCopy,
onViewDetail, onViewDetail,
onResubmit, onResubmit,
onComplaint, onComplaint,
}: { }: {
a5?: boolean
order: OrderLike order: OrderLike
onCopy: () => void onCopy: () => void
onViewDetail: () => void onViewDetail: () => void
@ -228,10 +201,10 @@ function OrderCard ({
onComplaint: () => void onComplaint: () => void
}) { }) {
const isMapLabel = order.goods_type === 'map_label' const isMapLabel = order.goods_type === 'map_label'
const number = a5 ? order.order_id : order.transaction_id const number = order.order_id
const status = (a5 && A5_STATUSES[order.process_status]) || getStatusMeta(order.process_status, order.process_status_name) const status = A5_STATUSES[order.process_status] || { result: order.process_status_name || '未知', color: '#666666', bg: '#F5F5F5' }
const actions = a5 ? { complaint: true, submit: true, detail: order.process_status === '2' } : getOrderCardActions(order) const canViewDetail = order.process_status === '2'
const canSubmit = isMapLabel && actions.submit const canSubmit = isMapLabel
return ( return (
<View className='order-card'> <View className='order-card'>
@ -246,7 +219,7 @@ function OrderCard ({
<InfoLine label='订单时间:' value={order.create_time || order.pay_time || '-'} /> <InfoLine label='订单时间:' value={order.create_time || order.pay_time || '-'} />
<View className='order-card__row'> <View className='order-card__row'>
<Text className='order-card__label'>{a5 ? '订单编号:' : '交易单号:'}</Text> <Text className='order-card__label'></Text>
<Text className='order-card__value order-card__value--id'>{number || '-'}</Text> <Text className='order-card__value order-card__value--id'>{number || '-'}</Text>
{number ? ( {number ? (
<Image className='order-card__copy' src={COPY_ICON} onClick={onCopy} /> <Image className='order-card__copy' src={COPY_ICON} onClick={onCopy} />
@ -262,24 +235,18 @@ function OrderCard ({
<View className='order-card__reject'>{order.reject_reason}</View> <View className='order-card__reject'>{order.reject_reason}</View>
) : null} ) : null}
{actions.complaint || canSubmit || actions.detail ? ( <View className='order-card__divider' />
<> <View className='order-card__actions'>
<View className='order-card__divider' /> <View className='order-card__action' onClick={onComplaint}></View>
<View className='order-card__actions'> {canViewDetail ? (
{actions.complaint ? ( <View className='order-card__action' onClick={onViewDetail}></View>
<View className='order-card__action' onClick={onComplaint}></View> ) : null}
) : null} {canSubmit ? (
{actions.detail ? ( <View className='order-card__action order-card__action--primary' onClick={onResubmit}>
<View className='order-card__action' onClick={onViewDetail}></View>
) : null}
{canSubmit ? (
<View className='order-card__action order-card__action--primary' onClick={onResubmit}>
</View>
) : null}
</View> </View>
</> ) : null}
) : null} </View>
</View> </View>
) )
} }

View File

@ -1,215 +0,0 @@
import { Button, Input, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import { updateOrderExtra } from '@/api/pay'
import { getCompanies } from '@/api/user'
import {
buildCorpResubmitPayload,
getCorpResubmitValues,
validateIDCard,
type CompanyItem,
type CorpResubmitValues,
} from '@/lib/company'
import { isValidMobilePhone } from '@/lib/phone'
import type { OrderLike } from '@/lib/order'
type Props = {
order: OrderLike
}
export default function CorpResubmit ({ order }: Props) {
const [values, setValues] = useState<CorpResubmitValues>(() => getCorpResubmitValues(order))
const [companies, setCompanies] = useState<CompanyItem[]>([])
const [querying, setQuerying] = useState(false)
const [submitting, setSubmitting] = useState(false)
const updateField = <K extends keyof CorpResubmitValues>(field: K, value: CorpResubmitValues[K]) => {
setValues((current) => ({ ...current, [field]: value }))
}
const handleCompanyNameChange = (value: string) => {
setValues((current) => ({
...current,
entity_name: value,
entity_id: '',
entity_legal: '',
}))
setCompanies([])
}
const handleQueryCompany = async () => {
const keyword = values.entity_name.replace(/\s/g, '')
if (!keyword) {
Taro.showToast({ title: '查询关键字不能为空', icon: 'none' })
return
}
if (querying) return
setQuerying(true)
try {
const res = await getCompanies(keyword)
const list = (Array.isArray(res.data) ? res.data : [])
.filter((item: CompanyItem) => item?.credit_code)
setCompanies(list)
if (!list.length) {
Taro.showToast({ title: '未查询到公司', icon: 'none' })
}
} catch {
setCompanies([])
Taro.showToast({ title: '查询公司失败,请稍后再试', icon: 'none' })
} finally {
setQuerying(false)
}
}
const handleSelectCompany = (company: CompanyItem) => {
setValues((current) => ({
...current,
entity_name: company.name,
entity_id: company.credit_code,
entity_legal: company.oper_name,
}))
setCompanies([])
}
const handleSubmit = async () => {
if (submitting) return
if (!values.entity_name || !values.entity_id || !values.entity_legal) {
Taro.showToast({ title: '请查询并选择公司', icon: 'none' })
return
}
if (!values.entity_idno || !values.entity_phone) {
Taro.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
if (!validateIDCard(values.entity_idno)) {
Taro.showToast({ title: '请填写正确身份证号', icon: 'none' })
return
}
if (!isValidMobilePhone(values.entity_phone)) {
Taro.showToast({ title: '请填写正确手机号', icon: 'none' })
return
}
setSubmitting(true)
try {
await updateOrderExtra(buildCorpResubmitPayload(order, values))
Taro.showToast({ title: '重新提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500)
} catch {
Taro.showToast({ title: '重新提交失败,请稍后再试', icon: 'none' })
} finally {
setSubmitting(false)
}
}
return (
<View className='resubmit-page resubmit-page--corp'>
{order.reject_reason ? (
<View className='resubmit-page__reject'>{order.reject_reason}</View>
) : null}
<View className='resubmit-page__body'>
<View className='resubmit-card'>
<View className='resubmit__field resubmit__field--company'>
<CorpFieldLabel text='公司名称' />
<View className='resubmit__control resubmit__control--company'>
<Input
className='resubmit__input'
value={values.entity_name}
placeholder='请输入营业执照名称'
placeholderClass='resubmit__placeholder'
onInput={(event) => handleCompanyNameChange(event.detail.value)}
/>
<Button
className='resubmit__query-company'
loading={querying}
disabled={querying}
onClick={() => void handleQueryCompany()}
>
{querying ? '查询中' : '查询'}
</Button>
</View>
{companies.length ? (
<View className='resubmit__company-list'>
{companies.map((company, index) => (
<View
className='resubmit__company-item'
key={`${company.credit_code}-${index}`}
onClick={() => handleSelectCompany(company)}
>
<Text className='resubmit__company-name'>{company.name}</Text>
<Text className='resubmit__company-legal'>{company.oper_name}</Text>
</View>
))}
</View>
) : null}
</View>
{values.entity_id ? (
<View className='resubmit__selected-company'>
<View className='resubmit__selected-company-main'>
<Text className='resubmit__selected-company-name'>{values.entity_name}</Text>
<Text className='resubmit__selected-company-code'>({values.entity_id})</Text>
</View>
<Text className='resubmit__selected-company-legal'>{values.entity_legal}</Text>
</View>
) : null}
<View className='resubmit__field'>
<CorpFieldLabel text='身份证号' />
<View className='resubmit__control'>
<Input
className='resubmit__input'
maxlength={18}
value={values.entity_idno}
placeholder='请输入法人身份证号'
placeholderClass='resubmit__placeholder'
onInput={(event) => {
const value = event.detail.value.toUpperCase().replace(/[^\dX]/g, '').slice(0, 18)
updateField('entity_idno', value)
}}
/>
</View>
</View>
<View className='resubmit__field'>
<CorpFieldLabel text='手机号码' />
<View className='resubmit__control'>
<Input
className='resubmit__input'
type='number'
maxlength={11}
value={values.entity_phone}
placeholder='请输入法人手机号'
placeholderClass='resubmit__placeholder'
onInput={(event) => updateField('entity_phone', event.detail.value.replace(/\D/g, '').slice(0, 11))}
/>
</View>
</View>
</View>
</View>
<View className='resubmit-page__footer'>
<Button
className='resubmit__submit resubmit__submit--corp'
loading={submitting}
disabled={submitting}
onClick={() => void handleSubmit()}
>
</Button>
</View>
</View>
)
}
function CorpFieldLabel ({ text }: { text: string }) {
return (
<View className='resubmit__label'>
<Text className='resubmit__required resubmit__required--leading'>*</Text>
<Text>{text}</Text>
</View>
)
}

View File

@ -110,88 +110,6 @@
line-height: 36px; line-height: 36px;
} }
.resubmit__shots {
display: flex;
align-items: flex-start;
}
.resubmit__shot,
.resubmit__example {
position: relative;
width: 292px;
height: 200px;
overflow: hidden;
border-radius: 16px;
background: #fff;
}
.resubmit__example {
margin-left: 20px;
flex-shrink: 0;
}
.resubmit__example-image {
width: 100%;
height: 100%;
}
.resubmit__example-caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
height: 48px;
color: #fff;
font-size: 24px;
line-height: 48px;
background: rgba(0, 0, 0, 0.55);
}
.resubmit__shot--empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px solid #e5e6eb;
}
.resubmit__shot-image {
width: 100%;
height: 100%;
}
.resubmit__shot-camera {
width: 56px;
height: 56px;
}
.resubmit__shot-text {
margin-top: 8px;
color: #c9cdd4;
font-size: 22px;
}
.resubmit__shot-edit {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 0 16px 0 16px;
background: rgba(29, 33, 41, 0.55);
}
.resubmit__shot-edit-icon {
width: 32px;
height: 32px;
}
.resubmit__disclaimer, .resubmit__disclaimer,
.resubmit__online { .resubmit__online {
display: block; display: block;
@ -261,137 +179,36 @@
border: 0; border: 0;
} }
.resubmit-page--corp .resubmit-card { .resubmit-page--a5 {
padding-bottom: 12px; .resubmit-card { width: 100%; }
.resubmit__control-value, .resubmit__error { overflow-wrap: anywhere; }
} }
.a5-doc {
.resubmit-page--corp .resubmit__field { margin-bottom: 28px;
margin-bottom: 36px; &:last-child { margin-bottom: 0; }
&__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
&__header .resubmit__label { margin-bottom: 0; }
&__example { flex-shrink: 0; font-size: 24px; color: #165dff; }
&__loading { display: block; margin-bottom: 16px; font-size: 24px; color: #86909c; }
&__grid { display: flex; gap: 20px; }
&__item { flex: 1; min-width: 0; max-width: calc((100% - 40px) / 3); text-align: center; }
&__media { width: 100%; aspect-ratio: 1; box-sizing: border-box; background: #fff; border: 2px solid #e5e6eb; border-radius: 16px; overflow: hidden; position: relative; display: flex; align-items: center; justify-content: center; }
&__image { width: 100%; height: 100%; }
&__upload { width: 100%; height: 100%; margin: 0; padding: 12px 0; display: flex; flex-direction: column; justify-content: center; align-items: center; background: transparent; font-size: 22px; line-height: 1.5; color: #c9cdd4; }
&__upload[disabled] { background: transparent; color: #c9cdd4; }
&__upload::after, &__remove::after { border: 0; }
&__camera { width: 56px; height: 56px; margin-bottom: 8px; }
&__remove { position: absolute; top: 0; right: 0; width: 48px; height: 48px; padding: 0; line-height: 44px; border-radius: 0 16px 0 16px; background: rgba(29, 33, 41, .55); color: #fff; font-size: 32px; }
&__caption { display: block; font-size: 24px; color: #1d2129; margin-top: 12px; }
&__progress { position: absolute; bottom: 0; left: 0; right: 0; background: #165dff; color: white; text-align: center; font-size: 22px; }
} }
.a5-example {
.resubmit__required--leading { position: fixed; inset: 0; z-index: 1200; background: rgba(0, 0, 0, .65); display: flex; align-items: center; justify-content: center; padding: 28px; box-sizing: border-box;
margin-right: 6px; &__dialog { width: 100%; max-height: 85vh; overflow-y: auto; padding: 28px; background: white; border-radius: 16px; box-sizing: border-box; }
margin-left: 0; &__title { display: block; text-align: center; font-size: 32px; font-weight: 600; margin: 8px 0 28px; }
} &__image { width: 100%; height: 180px; }
&__subtitle { display: block; font-size: 28px; font-weight: 600; margin: 28px 0 16px; }
.resubmit__control--company { &__requirements { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; font-size: 24px; color: #666; }
padding-right: 12px; .a5-doc__item { max-width: none; }
} .resubmit__submit { margin-top: 28px; }
.resubmit__query-company {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 120px;
height: 64px;
margin: 0 0 0 12px;
padding: 0;
border: 0;
border-radius: 12px;
background: #fc6c00;
color: #fff;
font-size: 26px;
line-height: 64px;
}
.resubmit__query-company::after {
border: 0;
}
.resubmit__company-list {
position: relative;
z-index: 2;
max-height: 420px;
margin-top: 8px;
overflow-y: auto;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
}
.resubmit__company-item {
display: flex;
align-items: center;
box-sizing: border-box;
min-height: 96px;
padding: 16px 20px;
border-bottom: 2px solid #eee;
}
.resubmit__company-item:last-child {
border-bottom: 0;
}
.resubmit__company-name {
flex: 1;
min-width: 0;
color: #1a1a1a;
font-size: 26px;
line-height: 36px;
}
.resubmit__company-legal {
flex-shrink: 0;
max-width: 160px;
margin-left: 20px;
overflow: hidden;
color: #fc6c00;
font-size: 24px;
line-height: 36px;
text-overflow: ellipsis;
white-space: nowrap;
}
.resubmit__selected-company {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
min-height: 128px;
margin: -16px 0 36px;
padding: 20px 24px;
border-radius: 12px;
background: rgba(252, 108, 0, 0.06);
}
.resubmit__selected-company-main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
}
.resubmit__selected-company-name,
.resubmit__selected-company-code,
.resubmit__selected-company-legal {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resubmit__selected-company-name {
color: #af5715;
font-size: 28px;
font-weight: 600;
line-height: 40px;
}
.resubmit__selected-company-code {
margin-top: 4px;
color: #fc6c00;
font-size: 24px;
line-height: 34px;
}
.resubmit__selected-company-legal {
flex-shrink: 0;
max-width: 180px;
margin-left: 20px;
color: #8b7c6f;
font-size: 26px;
}
.resubmit__submit--corp {
background: #df0000;
} }

View File

@ -1,307 +1,140 @@
import { Button, Image, Text, View } from '@tarojs/components' import { Button, Text, View } from '@tarojs/components'
import Taro, { useDidShow, useLoad } from '@tarojs/taro' import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { useState } from 'react' import { useRef, useState } from 'react'
import PrivacyConsent from '@/components/privacy-consent' import { a5Request, deleteA5File, getA5Draft, getA5Files, uploadA5Image, type A5Draft } from '@/lib/a5/api'
import { updateOrderExtra } from '@/api/pay' import { buildA5Update, STOREFRONT_LOCS, storefrontFromFiles } from '@/lib/a5/data'
import { uploadImage } from '@/api/user' import { formatMapCoordinate, getInitialPhone2, getOrderAddressName, getOrderExtraImages, getOrderMapAddress, parseCoordinate, parseStorefrontSlots, type ResubmitValues, type StorefrontSlot } from '@/lib/order'
import { LOCAL_IMAGES } from '@/lib/assets'
import { ENTITY_LABELS, MAP_ASSET_BASE } from '@/lib/cdn'
import { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent'
import { isCorpGoodsType } from '@/lib/detail'
import { getOrderDraft } from '@/lib/orderDraft'
import { isValidPhone } from '@/lib/phone' import { isValidPhone } from '@/lib/phone'
import {
buildResubmitPayload,
formatMapCoordinate,
getInitialPhone2,
getOrderAddressName,
getOrderExtraImages,
getOrderMapAddress,
parseCoordinate,
parseStorefrontSlots,
type OrderLike,
type ResubmitValues,
type StorefrontSlot,
type UploadedImage,
} from '@/lib/order'
import { consumeMapPickerResult } from '@/pages/map/picker' import { consumeMapPickerResult } from '@/pages/map/picker'
import { FieldLabel, MapResubmitFields, ResubmitNotes } from './fields' import PrivacyConsent from '@/components/privacy-consent'
import CorpResubmit from './corp' import { chooseA5Image, imageErrorMessage } from '@/lib/a5/image'
import { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent'
import { MapResubmitFields, ResubmitNotes } from './fields'
import Documents from './documents'
import './index.scss' import './index.scss'
const STOREFRONT_LABELS = ['门头正面', '门头左面', '门头右面'] export default function A5Resubmit () {
const STOREFRONT_EXAMPLES = [ const [draft, setDraft] = useState<A5Draft | null>(null)
LOCAL_IMAGES.exampleStorefrontFront,
LOCAL_IMAGES.exampleStorefrontRight,
LOCAL_IMAGES.exampleStorefrontLeft,
]
function ImageSlot ({
value,
uploading,
progress,
onChange,
}: {
value: StorefrontSlot
uploading: boolean
progress: number
onChange: (filePath: string) => void
}) {
const handleChoose = async () => {
const res = await Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
})
const filePath = res.tempFilePaths?.[0]
if (filePath) onChange(filePath)
}
if (value) {
return (
<View className='resubmit__shot'>
<Image
className='resubmit__shot-image'
src={value.preview || value.url}
mode='aspectFill'
onClick={() => Taro.previewImage({ current: value.preview || value.url, urls: [value.preview || value.url] })}
/>
<View className='resubmit__shot-edit' onClick={() => void handleChoose()}>
<Image className='resubmit__shot-edit-icon' src={LOCAL_IMAGES.pen} />
</View>
</View>
)
}
return (
<View className='resubmit__shot resubmit__shot--empty' onClick={() => void handleChoose()}>
<Image className='resubmit__shot-camera' src={LOCAL_IMAGES.camera} />
<Text className='resubmit__shot-text'>
{uploading ? `上传中 ${Math.round(progress)}%` : '请上传图片'}
</Text>
</View>
)
}
export default function ResubmitPage () {
const [order, setOrder] = useState<OrderLike | null>(null)
const [values, setValues] = useState<ResubmitValues>({}) const [values, setValues] = useState<ResubmitValues>({})
const [storefrontSlots, setStorefrontSlots] = useState<StorefrontSlot[]>([null, null, null]) const [slots, setSlots] = useState<StorefrontSlot[]>(Array(6).fill(null))
const [licenseImages, setLicenseImages] = useState<UploadedImage[]>([]) const [loading, setLoading] = useState(true)
const [uploadingField, setUploadingField] = useState<'storefront0' | 'storefront1' | 'storefront2' | 'license' | null>(null) const [filesReady, setFilesReady] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0) const [busy, setBusy] = useState<number | null>(null)
const [formError, setFormError] = useState('') const [progress, setProgress] = useState(0)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [loadError, setLoadError] = useState('')
const [agreed, setAgreed] = useState(hasPrivacyConsent) const [agreed, setAgreed] = useState(hasPrivacyConsent)
const lock = useRef(false)
const returningMap = useRef(false)
async function loadFiles (current: A5Draft) {
setLoading(true)
setFilesReady(false)
setLoadError('')
try {
const [storefront, license, identity] = await Promise.all(['order_biz2', 'order_biz1', 'order_biz3'].map((scene) => getA5Files(current.linkId, String(current.order.order_id), scene)))
setSlots((previous) => [
...(storefront.length ? storefrontFromFiles(storefront) : previous.slice(0, 3)),
...(license.length ? [license[license.length - 1]] : previous.slice(3, 4)),
...(identity.length ? [identity.slice(-2)[0] || null, identity.slice(-2)[1] || null] : previous.slice(4, 6)),
])
setFilesReady(true)
} catch (reason) {
setLoadError(imageErrorMessage(reason, '图片加载失败'))
} finally { setLoading(false) }
}
useLoad(() => { useLoad(() => {
const draft = getOrderDraft() const current = getA5Draft()
if (!draft) { if (!current) {
Taro.showToast({ title: '订单不存在', icon: 'none' }) setLoading(false)
setTimeout(() => Taro.navigateBack(), 400) setError('订单不存在,请返回查询页重新进入')
return return
} }
setOrder(draft) consumeMapPickerResult()
setValues({ setDraft(current)
entity_name: draft.entity_name || '', const order = current.order
entity_address_name: getOrderAddressName(draft), setValues({ entity_name: order.entity_name || '', entity_phone: order.entity_phone || '', entity_phone2: getInitialPhone2(order), entity_address_name: getOrderAddressName(order), entity_address: getOrderMapAddress(order) })
entity_address: getOrderMapAddress(draft), const license = getOrderExtraImages(order, ['entity_business_license_image', 'business_license_image'])
entity_phone: draft.entity_phone || '', const identity = getOrderExtraImages(order, ['entity_identity_card_image', 'identity_card_image'])
entity_phone2: getInitialPhone2(draft), setSlots([...parseStorefrontSlots(order), license[0] || null, identity[0] || null, identity[1] || null])
}) void loadFiles(current)
setStorefrontSlots(parseStorefrontSlots(draft))
setLicenseImages(getOrderExtraImages(draft, ['entity_business_license_image', 'business_license_image']).slice(0, 1))
}) })
useDidShow(() => { useDidShow(() => {
const result = consumeMapPickerResult() if (!returningMap.current) return
if (!result) return returningMap.current = false
const mapAddress = formatMapCoordinate(result) const point = consumeMapPickerResult()
const mapAddressName = result.address || result.name || '' if (point) setValues((current) => ({ ...current, entity_address: formatMapCoordinate(point), entity_address_name: point.address || point.name || current.entity_address_name }))
setValues((current) => ({
...current,
entity_address: mapAddress,
entity_address_name: mapAddressName || current.entity_address_name,
}))
}) })
const disabled = loading || busy !== null || submitting
const updateField = <K extends keyof ResubmitValues>(field: K, value: ResubmitValues[K]) => { const update = (field: keyof ResubmitValues, value: string) => setValues((current) => ({ ...current, [field]: value }))
setValues((current) => ({ ...current, [field]: value })) async function upload (index: number) {
} if (!draft || disabled || !filesReady || lock.current) return
lock.current = true
const requireConsent = () => {
if (agreed) return true
Taro.showToast({ title: '请先阅读并同意服务协议和隐私政策', icon: 'none' })
return false
}
const handleConsentChange = (nextAgreed: boolean) => {
setAgreed(nextAgreed)
savePrivacyConsent(nextAgreed)
}
const handleUpload = async (field: 'storefront0' | 'storefront1' | 'storefront2' | 'license', filePath: string) => {
setUploadingField(field)
setUploadProgress(0)
setFormError('')
try { try {
const uploaded = await uploadImage(filePath, (percent) => setUploadProgress(percent)) setError('')
const image: UploadedImage = { id: uploaded.id, url: uploaded.url, preview: filePath } const path = await chooseA5Image()
if (field === 'license') { if (!path) return
setLicenseImages([image]) setBusy(index)
return setProgress(0)
} setError('')
const index = Number(field.replace('storefront', '')) const uploaded = await uploadA5Image(path, draft.linkId, String(draft.order.order_id), index < 3 ? 'order_biz2' : index === 3 ? 'order_biz1' : 'order_biz3', index < 3 ? { loc: STOREFRONT_LOCS[index] } : undefined, setProgress)
setStorefrontSlots((current) => { setSlots((current) => current.map((slot, position) => position === index ? { ...uploaded, preview: path } : slot))
const next = [...current] } catch (reason) {
next[index] = image setError(imageErrorMessage(reason))
return next } finally { setBusy(null); lock.current = false }
})
} catch (error) {
setFormError(error instanceof Error ? error.message : '图片上传失败,请稍后重试')
} finally {
setUploadingField(null)
setUploadProgress(0)
}
} }
async function remove (index: number) {
const handleChooseMap = () => { if (disabled || !filesReady || lock.current) return
const point = parseCoordinate(values.entity_address) lock.current = true
const query = [ try {
point ? `lng=${point.lng}` : '', const confirmation = await Taro.showModal({ title: '删除照片', content: '确定删除这张照片?' })
point ? `lat=${point.lat}` : '', if (!confirmation.confirm) return
values.entity_address_name ? `name=${encodeURIComponent(values.entity_address_name)}` : '', setBusy(index)
].filter(Boolean).join('&') setProgress(0)
Taro.navigateTo({ if (slots[index]?.id) await deleteA5File(slots[index]!.id)
url: query ? `/pages/map/index?${query}` : '/pages/map/index', setSlots((current) => current.map((slot, position) => position === index ? null : slot))
}) } catch (reason) { setError(reason instanceof Error ? reason.message : '删除失败') } finally { setBusy(null); lock.current = false }
} }
async function submit () {
const handleSubmit = async () => { if (!draft || disabled || !filesReady || lock.current) return
if (!order || submitting) return const invalid = !agreed ? '请先阅读并同意协议'
if (!requireConsent()) return : !values.entity_name?.trim() ? '请输入门店名称'
if (uploadingField) { : !values.entity_address_name?.trim() ? '请输入实际经营地址'
setFormError('图片正在上传,请稍候') : !values.entity_address ? '请选择门店位置'
return : !isValidPhone(values.entity_phone || '') ? '请填写正确的门店电话'
} : !isValidPhone(values.entity_phone2 || '') ? '请填写正确的联系人电话'
if (!values.entity_name?.trim()) { : slots.slice(0, 3).some((slot) => !slot?.id) ? '请上传门头正面、左面和右面照片' : ''
Taro.showToast({ title: `请输入${ENTITY_LABELS.entityName}`, icon: 'none' }) if (invalid) { Taro.showToast({ title: invalid, icon: 'none' }); return }
return lock.current = true
}
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: '请填写正确的门店电话', icon: 'none' })
return
}
if (!isValidPhone(values.entity_phone2 || '')) {
Taro.showToast({ title: '请填写正确的联系人电话', icon: 'none' })
return
}
setSubmitting(true) setSubmitting(true)
setFormError('')
try { try {
await updateOrderExtra(buildResubmitPayload({ await a5Request('/h5/order', { method: 'PUT', data: buildA5Update(draft.order, values, slots) })
order,
values,
storefrontSlots,
licenseImages,
}))
Taro.showToast({ title: '重新提交成功', icon: 'success' }) Taro.showToast({ title: '重新提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500) setTimeout(() => Taro.navigateBack(), 500)
} catch { } catch (reason) { setError(reason instanceof Error ? reason.message : '重新提交失败') } finally { setSubmitting(false); lock.current = false }
Taro.showToast({ title: '重新提交失败,请稍后再试', icon: 'none' })
} finally {
setSubmitting(false)
}
} }
function chooseMap () {
if (order && isCorpGoodsType(order.goods_type)) { const point = parseCoordinate(values.entity_address)
return <CorpResubmit order={order} /> consumeMapPickerResult()
returningMap.current = true
void Taro.navigateTo({ url: `/pages/map/index?name=${encodeURIComponent(values.entity_address_name || '')}${point ? `&lng=${point.lng}&lat=${point.lat}` : ''}` })
} }
return <View className='resubmit-page resubmit-page--a5'>
return ( {draft?.order.reject_reason ? <View className='resubmit-page__reject'>{draft.order.reject_reason}</View> : null}
<View className='resubmit-page'> <View className='resubmit-page__body resubmit-page__body--consent'>
{order?.reject_reason ? ( <View className='resubmit-card'>
<View className='resubmit-page__reject'>{order.reject_reason}</View> <MapResubmitFields values={values} onChange={update} onChooseMap={chooseMap} />
) : null} {loading ? <Text className='a5-doc__loading'>...</Text> : null}
<Documents slots={slots} busy={busy} progress={progress} disabled={disabled || !filesReady} onUpload={(index) => void upload(index)} onRemove={(index) => void remove(index)} />
<View className='resubmit-page__body resubmit-page__body--consent'>
<View className='resubmit-card'>
<MapResubmitFields values={values} onChange={updateField} onChooseMap={handleChooseMap} />
{STOREFRONT_LABELS.map((label, index) => (
<View className='resubmit__field' key={label}>
<FieldLabel icon={`${MAP_ASSET_BASE}/pay-success-storefront.png`} text={label} />
<View className='resubmit__shots'>
<ImageSlot
value={storefrontSlots[index] || null}
uploading={uploadingField === `storefront${index}`}
progress={uploadProgress}
onChange={(filePath) => void handleUpload(`storefront${index}` as 'storefront0' | 'storefront1' | 'storefront2', filePath)}
/>
{storefrontSlots[index] ? null : (
<View className='resubmit__example'>
<Image
className='resubmit__example-image'
src={STOREFRONT_EXAMPLES[index]}
mode='aspectFill'
/>
<View className='resubmit__example-caption'>
<Text></Text>
</View>
</View>
)}
</View>
</View>
))}
<View className='resubmit__field'>
<FieldLabel icon={`${MAP_ASSET_BASE}/pay-success-license.png`} text='营业执照' />
<View className='resubmit__shots'>
<ImageSlot
value={licenseImages[0] || null}
uploading={uploadingField === 'license'}
progress={uploadProgress}
onChange={(filePath) => void handleUpload('license', filePath)}
/>
{licenseImages[0] ? null : (
<Image
className='resubmit__example'
src={LOCAL_IMAGES.exampleLicense}
mode='aspectFill'
/>
)}
</View>
</View>
</View>
<ResubmitNotes />
{formError ? <Text className='resubmit__error'>{formError}</Text> : null}
</View>
<View className='resubmit-page__footer resubmit-page__footer--consent'>
<PrivacyConsent
className='resubmit__privacy'
agreed={agreed}
onChange={handleConsentChange}
/>
<Button
className='resubmit__submit'
loading={submitting}
disabled={Boolean(uploadingField)}
onClick={() => void handleSubmit()}
>
</Button>
</View> </View>
<ResubmitNotes />
{loadError ? <View className='resubmit__error'>{loadError}{draft ? <Text onClick={() => !disabled && void loadFiles(draft)}> </Text> : null}</View> : null}
{error ? <View className='resubmit__error'>{error}</View> : null}
</View> </View>
) <View className='resubmit-page__footer resubmit-page__footer--consent'>
<PrivacyConsent className='resubmit__privacy' agreed={agreed} onChange={(value) => { setAgreed(value); savePrivacyConsent(value) }} />
<Button className='resubmit__submit' loading={submitting} disabled={disabled || !filesReady || !draft} onClick={() => void submit()}></Button>
</View>
</View>
} }

6
types/global.d.ts vendored
View File

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