Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fb7ab7abb | |||
| 905e145af3 | |||
| 7dd5fdfb12 | |||
| 48d6900ab6 | |||
| 5ca9aa00aa | |||
| c0d04ee500 | |||
| 3ac008a217 | |||
| cd1e9ae488 | |||
| 18147e157f | |||
| f7c94b4f16 | |||
| cc750dc1d6 | |||
| 984dec79ee | |||
| f84614bbb4 | |||
| c0ce494732 | |||
| 69e201f927 | |||
| ecd9973d8f | |||
| bbd7e52e70 | |||
| 1c91e6bae9 | |||
| 029fd690a5 | |||
| 0bfc19b1a4 | |||
| bafb342600 | |||
| bac5c7ad80 | |||
| c05cd0e96d | |||
| 8de41796fb | |||
| 4b3b1f76ae | |||
| 75d820b574 | |||
| 0f48d7abda | |||
| 4a0e18472b | |||
| 491f03e825 | |||
| 88053353ab | |||
| ceacce9ed5 | |||
| 3913bf3bf3 | |||
| 478291e2b3 | |||
| 6754caf076 | |||
| 92c3175acf | |||
| 46361ec713 | |||
| 5586ed90cd | |||
| 4cec5b1fe5 | |||
| 07630c9046 | |||
| 4488b0c006 | |||
| 99c74f20d2 | |||
| a598d1cd30 | |||
| da6a38daa4 | |||
| c5926bd593 | |||
| df546ff39f | |||
| d6d4a77473 | |||
| 0518976953 | |||
| 8f83624ffe | |||
| 7c6dae472c | |||
| 209db59422 | |||
| 21d44026d9 | |||
| 3b192b0b88 | |||
| 1b3a7f3a36 | |||
| 34a1897c09 | |||
| 36017a19b1 | |||
| 9c7550a1c4 | |||
| fe1c6c8158 | |||
| acc841bb21 | |||
| 61a99aeea9 | |||
| 76375eff83 | |||
| 713dce52b4 | |||
| e41bf838d4 | |||
| 0692bebf07 | |||
| b19f875e7b | |||
| 090002b6c0 | |||
| 8726cf14e3 | |||
| 7298b1a5d5 | |||
| 1401f1dcd2 | |||
| 155426b669 | |||
| b4d6f2bd91 | |||
| 029c17347e | |||
| b626db5d84 | |||
| d98ef6b808 | |||
| d13a6ee3cd | |||
| 10fa361983 | |||
| 1a615ffe93 | |||
| 0269802e59 | |||
| e4e9eb2169 | |||
| b5fb9a0bb2 | |||
| 9d800a4cbd | |||
| 2a9493165b | |||
| b32c68d56b | |||
| 6816835fb8 | |||
| 571959900b | |||
| 7bc710f139 | |||
| 397bdad73f | |||
| 83b1b0e5f6 | |||
| 91fed31b10 | |||
| 1933b9b608 | |||
| a2abbb6169 | |||
| 53a79d1255 | |||
| 0181af09c5 | |||
| 9cd0c9508a | |||
| 4f363fa9e7 | |||
| 291137a15d | |||
| e2a5f21f9d | |||
| 3ef4432b2f | |||
| 33b868dfac | |||
| 5c4ad1d8db | |||
| a346fae7a4 | |||
| 37bc297d1a | |||
| bc1616c3f7 | |||
| 46a200e0ba | |||
| 429eaabe6b | |||
| 607a0224cd | |||
| 53e0db6a98 | |||
| 456b3af461 | |||
| 1f7ecffe4c | |||
| 8df0a2bea7 | |||
| 672a995c59 | |||
| 69f52921a7 | |||
| 9cb88dc181 | |||
| 345d5db9c9 | |||
| 7c9240fc84 | |||
| 2610288df0 | |||
| 62652cd072 | |||
| 9d49ed5e70 | |||
| bdbd135318 | |||
| 8ca893efab | |||
| d0ab838da6 | |||
| 50ab0f1748 | |||
| fa6fb80d19 | |||
| bd1930f6b1 | |||
| dc4295d1bd | |||
| 256526c5ec | |||
| 1c4b986924 | |||
| bf501d2374 | |||
| 442500b1a1 | |||
| 490c26a7e1 | |||
| b40eb83f27 | |||
| f80a0f2c27 | |||
| 528669f662 | |||
| f5e523a425 | |||
| 16fa07ceaf | |||
| b21403a47c | |||
| 02d89e6385 | |||
| f9a5b6e81a | |||
| 16c386f165 | |||
| 1c787ed576 | |||
| 4372018c09 | |||
| aca3df4bf2 | |||
| 71f508e02b | |||
| 9d96c73246 |
@@ -39,6 +39,9 @@ src/
|
||||
|
||||
## UI 约束
|
||||
|
||||
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
|
||||
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`(Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||
|
||||
- C 端订单 **5 Tab**(含 pending_ship)
|
||||
- 门店列表仅 `OPEN` 状态
|
||||
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
|
||||
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# mini-user · 微信 open-type 硬规则
|
||||
|
||||
## 禁止(必踩坑)
|
||||
|
||||
**现象**:`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
|
||||
|
||||
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
|
||||
|
||||
```tsx
|
||||
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
|
||||
<View className="mask" onClick={close}>
|
||||
<View className="sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
|
||||
</View>
|
||||
</View>
|
||||
```
|
||||
|
||||
## 正确写法
|
||||
|
||||
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
|
||||
|
||||
```tsx
|
||||
// ✅
|
||||
<View className="mask">
|
||||
<View className="backdrop" onClick={close} />
|
||||
<View className="sheet">
|
||||
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
|
||||
...
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
```
|
||||
|
||||
## 附加
|
||||
|
||||
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
|
||||
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
|
||||
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
|
||||
|
||||
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
||||
|
||||
Vite 代理:`/api` → `localhost:3000`。
|
||||
Vite 代理:`/api` → `localhost:3010`(可用 `VITE_API_TARGET` 覆盖)。
|
||||
|
||||
## 共享包
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 2026-08-12 工作记录
|
||||
|
||||
## 杜康好客 · 三项功能需求落地(dev_jacy 分支)
|
||||
|
||||
### 需求1:管理端用户列表增加好客权益金额列
|
||||
- 后端 `server/dukang-api/src/modules/ops/admin-users.service.ts`:
|
||||
- 新增常量 `BENEFIT_STAT_STATUSES = ['ACTIVE','USED_UP']`、`UserBenefitStat` 类型、`EMPTY_BENEFIT_STAT`、`toAmount()`。
|
||||
- `list()` / `detail()` 均调用新增私有方法 `loadBenefitStats(userIds)`,用 `benefitCoupon.groupBy({ by:['userId'], where:{status:{in:[...BENEFIT_STAT_STATUSES]}}, _sum:{totalAmount,usedAmount,balance} })` 批量聚合,避免 N+1。
|
||||
- `mapAdminUserRow` 增加 `benefit` 参数并展开到返回对象。统计口径与 C 端 `listCoupons` 一致(排除 VOID)。
|
||||
- 前端 `apps/admin-web/src/lib/api.ts`:`AdminUserRow` 增加 `benefitTotalAmount?/benefitUsedAmount?/benefitBalance?: number`。
|
||||
- 前端 `apps/admin-web/src/pages/UsersPage.tsx`:新增「剩余权益/已用权益/累计权益」三列(右对齐、可排序,scroll x 改为 1830),详情抽屉 Descriptions 增加「好客权益」项。
|
||||
|
||||
### 需求2:管理端门店入驻合同支持多图上传
|
||||
- 新建共享工具 `server/dukang-api/src/common/store-media/contract-urls.util.ts`:`MAX_CONTRACT_FILES=20`、`normalizeContractUrls(urls?, legacy?)`、`contractMediaType(url)`(.pdf→FILE 否则 IMAGE)。
|
||||
- 后端 `admin-stores.service.ts`:import 上述工具;create(645 行)/ update(451 行)路径由单条 `contractUrl` 改为遍历建立多条 `bizType:'CONTRACT'` 记录(`sortOrder:i`、`mediaType` 按扩展名判定)。
|
||||
- DTO `admin-mutate.dto.ts`:`contractUrl?:string` 标记 `@deprecated`,新增 `contractUrls?:string[]`(`@IsArray @IsString({each:true}) @ArrayMaxSize(20)`)。
|
||||
- 前端 `apps/admin-web/src/lib/storeCreate.ts`:`StoreCreateForm.contractUrl?:string` → `contractUrls?:string[]`。
|
||||
- `MultiImageUpload.tsx`:新增 `isPdf()`、`FilePdfOutlined`、PDF 缩略图卡片、`buttonText` prop。
|
||||
- `StoresPage.tsx`:编辑抽屉 + 创建 step1 表单均改用 `MultiImageUpload`(name=contractUrls, maxCount=20, accept=image/*,.pdf, buttonText="批量上传合同"),回显/保存 payload 改为 `contractUrls:string[]`。
|
||||
|
||||
### 需求3:合伙人 H5 门店入驻合同支持多图上传
|
||||
|
||||
## 发布流水线(同一会话,22:45 后)
|
||||
|
||||
完整跑通 dukang-release 标准流程:`dev_jacy` → push → merge `dev` → merge `main` → 发生产。
|
||||
- 提交 `8805335` feat(ops,store,partner): 用户权益列 + 门店合同多图上传(13 文件,356+/59-)
|
||||
- `dev_jacy` push → `origin/dev_jacy`(8805335)
|
||||
- merge `dev_jacy`→`dev` → `491f03e`,push `origin/dev`
|
||||
- merge `dev`→`main` → `4a0e184`,push `origin/main`
|
||||
- `deploy/deploy-prod.sh -- --skip-db`(无 Prisma schema 变更,故 --skip-db)→ `DEPLOY_EXIT=0`,`==> 发版完成 (production)`
|
||||
- 生产 PM2 全 online:`dukang-api`(8090) / admin-web / h5-partner / h5-shop / h5-user;健康检查 `api:200` `mini-user(h5):200`;版本记录 commit=4a0e184
|
||||
|
||||
### 踩坑/注意
|
||||
- `git checkout main` 时本机工作树出现 `server/dukang-api` 大量 `D`(误删)假象:实为 sandbox 下 checkout 未刷新工作树,非仓库损坏。用 `git checkout -f main` 重新填充即恢复;`origin/main` 始终正确(ls-remote 核对 4a0e184)。
|
||||
- 提交/合并后 `git status` 报 "ahead of origin by N" 为本地 tracking ref 陈旧,非真实分歧(ls-remote 已核对远程三分支均含本次提交)。
|
||||
- 仅提交 13 个需求文件 + 新建 `server/dukang-api/src/common/store-media/`,已排除 `.workbuddy/` 与 `fix_acl.ps1`。
|
||||
- 发布命令在 sandbox 下直接运行 SSH 部署成功(未强制禁用沙箱)。
|
||||
- `apps/h5-partner/src/lib/storeDraft.ts`:`StoreDraftForm.contractUrl:string` → `contractUrls:string[]`;`defaultStoreForm` 改为 `contractUrls:[]`;新增 `MAX_CONTRACT_COUNT=20`;`normalizeStoreDraftForm` 新增 `contractUrls` 计算(兼容旧 `contractUrl` 迁移为数组,去重限 20);`validateStoreStep2` 参数/校验改为 `contractUrls`,不足 1 张返回「请上传签约合同」。
|
||||
- `MultiOssUploadField.tsx`:新增 `accept?`、`unit?` props,`isPdf()`,PDF 缩略图卡片。
|
||||
- `StoreCreatePage.tsx`:签约合同区块 `OssUploadField` → `MultiOssUploadField`(value=`form.contractUrls ?? []`, maxCount=MAX_CONTRACT_COUNT, unit="个", accept="image/*,.pdf");提交体 `contractUrl` → `contractUrls`,新增 `const contractUrls = Array.from(new Set(...)).slice(0, MAX_CONTRACT_COUNT)`;envPhotoUrls slice 上限由 3 改为 20。修正了渲染块误用未定义变量 `contractUrls`(已改为从 form 读取)。
|
||||
|
||||
### 验证
|
||||
- 后端 `npx tsc --noEmit`(server/dukang-api)通过,exit 0。
|
||||
- admin-web / h5-partner 使用 `git stash` 基线对比法:本次改动**未引入任何新类型错误**(现存错误均为预存在基线问题:css module 声明、`import.meta.env`、过期 config 类型、TabLayout `end`、staff `staffRole` 等)。
|
||||
- 向后兼容:旧 `contractUrl` 字段保留并标记 deprecated,H5 草稿旧数据自动迁移;避免破坏现有 API 客户端。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 2026-08-16
|
||||
|
||||
## 新增:线上库同步本地 CLI 工具
|
||||
- 文件:`deploy/sync-prod-db-to-local.sh`(新建,未提交)
|
||||
- 用途:将生产库 `dukang_prod`(阿里云 RDS `rm-bp139andp1aa385i9.mysql.rds.aliyuncs.com:3306`,用户 `dukangadmin`)同步到本地 Docker MySQL `dukang_haoke`(localhost:6016 / 容器内 `dukang-v1-mysql`,root/root)。
|
||||
- 原理:SSH 隧道借道 `dukang-server`(`-L 6018:rds:3306`)→ 本地无 mysql 客户端,故在 `dukang-v1-mysql` 容器内跑 mysqldump/mysql,经 `host.docker.internal:TUNNEL_PORT` 连隧道;管道直导,不落临时文件。
|
||||
- 安全:默认先 `docker exec mysqldump` 备份本地到 `deploy/backups/`;需 `--yes` 跳过交互确认;`--dry-run` 只读远端 DATABASE_URL 并打印计划。
|
||||
- 远端凭据从 `/opt/dukang/server/dukang-api/.env.production` 的 DATABASE_URL 运行时解析(密码经 `MYSQL_PWD` 环境变量传入,避免命令行暴露)。
|
||||
- 已知限制:不支持密码含 `@`;prod DB 名与本地不同(dukang_prod→dukang_haoke)靠 mysqldump 不带 `--databases` 实现库名映射。
|
||||
- 状态:仅跑通 `--dry-run`,未执行真实同步(待用户确认,因会覆盖本地库并拉取生产数据)。
|
||||
|
||||
## 修复:首次同步失败的根因
|
||||
- 失败现象:`mysqldump` 退出码 2,提示 `本地库可能处于不一致状态`(工具自身的失败兜底提示)。
|
||||
- 真实根因有两条:
|
||||
1. **密码解析错误**:生产 DB 密码含 `@` 字符(`passHasAt=true`)。原 bash 解析用「第一个 @」切分 userinfo/host,把密码里 `@` 之后的部分截断,导致 Access denied。
|
||||
修复:改为在服务端用 `new URL()` 解析,base64 回传各字段(user/host/port/db/password),本地 `base64 -d` 还原,彻底免疫 `@`/`:`/`$` 等特殊字符。服务端 auth 测试 `authTestStatus=0` 验证密码正确。
|
||||
2. **权限不足**:`dukangadmin`(应用库用户)无 `RELOAD`/`FLUSH_TABLES` 权限。`--single-transaction` 仍会触发全局 `FLUSH TABLES` → Access denied。
|
||||
修复:`mysqldump` 改用 `--lock-tables=0`(不发起 FTWRL)+ `--set-gtid-purged=OFF`(RDS 启用 GTID,否则本地恢复会报 GTID_PURGED 错误),并保留 `--skip-routines --skip-events --column-statistics=0 --no-tablespaces --add-drop-table --skip-triggers --no-create-db`。
|
||||
- 验证:结构导出(--no-data)在 4 组 flag 中仅 `--lock-tables=0` / `--skip-lock-tables`(不含 --single-transaction)退出 0。
|
||||
- 现工具已修正并实际执行同步(--yes,先自动备份本地到 deploy/backups/)。
|
||||
- 注意:`--lock-tables=0` 无事务一致性快照;生产库在线写入期间导出可能有极小不一致,但恢复时 mysqldump 自带 `FOREIGN_KEY_CHECKS=0`,_dev 库可接受。若需完全一致快照,可给 dukangadmin 授 RELOAD 后用 --single-transaction。
|
||||
|
||||
## 修改:套餐审核导航样式恢复一致
|
||||
- 文件:`apps/admin-web/src/layouts/AdminLayout.tsx` 的 `attachPackageAuditBadge`(`/store-package-audits` 项)。
|
||||
- 诉求演变:用户先要求「文字恒为白色」→ 后改为「不要单独处理,保持与子菜单一致,只是多一个待审徽章图标」。
|
||||
- 最终实现:label 改为 `<span style={{display:'inline-flex',gap:6}}>套餐审核{pendingCount>0 && <Badge count size="small"/>}</span>`。文字无强制颜色,随菜单主题继承 normal/hover/selected 颜色;徽章仅 pendingCount>0 时作为图标提示,不参与着色。
|
||||
|
||||
## 发布流水线(本次收尾,含之前未上线的 8+1 文件)
|
||||
- 背景:此前「联系电话脱敏+套餐多行输入+同步工具」等 9 文件 commit `8de4179` 已 push dev_jacy/dev/main,但**生产发布失败**(deploy.env 丢失导致 `未配置 DEPLOY_HOST`)。
|
||||
- 修复发布:`deploy.env` 是 gitignore 的本地密钥文件,已丢失不可找回;改用 SSH 别名 `dukang-server`(~/.ssh/config:HostName 47.110.129.57 / User root / IdentityFile pem)覆盖:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`。
|
||||
- 关键排查:`origin/*` 本地缓存 ref 多次陈旧(误报 ahead/behind),必须以 `git ls-remote` 为准。本次真实远端:dev_jacy=8de4179、dev=c05cd0e、main=bac5c7a,与本地一致,无丢失。
|
||||
- 本次提交:`bafb342` fix(admin) 套餐审核样式 → push dev_jacy → merge dev(`0bfc19b`) → merge main(`029fd69`) → push。
|
||||
- 发布结果:`DEPLOY_EXIT=0`,`==> 发版完成 (production)`,健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `029fd69`。
|
||||
- 提示:以后若想恢复标准 `bash deploy/deploy-prod.sh`(不带 --host),需重建 `deploy/deploy.env`(从 `deploy.env.example` 复制,填入 DEPLOY_HOST=47.110.129.57 等;该文件 gitignore,勿提交)。
|
||||
|
||||
## 排查:h5-partner 本地 /login 调 /partner/me 报 401
|
||||
- 现象:`http://localhost:5176/login` 启动后控制台报 `GET /api/v1/partner/me 401 (Unauthorized)`。
|
||||
- 结论:**不是 bug,是登录页的「过期 token 探测」**。链路:Vite 代理 `/api`→`localhost:3010`(正常);后端返回 `{"code":401,"message":"Missing token"}`(说明后端在线、代理通)。`ensureSession()`(api.ts:244)只在 localStorage 有 token 时才打 `/partner/me`;无 token 直接返回未登录。所以 401 来自之前测试残留的失效 token → 前端静默清除并展示登录表单,不崩、不弹错。刷新页面(token 已清)即不再出现。
|
||||
- 本地登录方式(关键,README 已过期):
|
||||
- `MOCK_SMS=true`(.env),短信走 mock,不连运营商;固定码 `MOCK_SMS_FIXED_CODE` 默认 `999888`(README 写的 123456 已不准)。
|
||||
- README 的测试号 `13700000001` **不在本地同步库**;本地 `partner_account` 共 9 个,可用真实号如 `18049821889`(Jacy) / `13073729990`(queen)(均 ACTIVE)。
|
||||
- 流程:填真实合伙人手机号 → 获取验证码 → **验证码打印在 `pnpm dev:api` 后端终端**(`Mock SMS → 180****8889 code=XXXXXX`)→ 填码登录。
|
||||
- 注意 h5-partner 默认 5175,本次因 5175 被占自动落到 5176,代理是路径匹配不受影响。
|
||||
|
||||
## 发布:v3.4.17 门店套餐审核快捷入口 上生产
|
||||
- 提交 `1c91e6b`(dev_jacy 上,含后端 admin-stores.service.ts + AdminStorePackagesSection/StorePackageAuditsPage/StoresPage 三前端 + 开发计划文档)。
|
||||
- 流水线:直接快进推送(避免 git checkout 工作树不刷新):`git push origin dev_jacy` + `dev_jacy:dev` + `dev_jacy:main`,三分支统一到 `1c91e6b`;本地 `git branch -f dev/main origin/*` 同步。
|
||||
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(后端仅给列表响应加字段,无 schema 变更,故 skip-db)。
|
||||
- 结果:`DEPLOY_EXIT=0`,新 tag `v3.4.17`,全部应用构建通过,PM2 重启 dukang-api/h5-shop/h5-partner/admin-web(另拉起 h5-user),健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `1c91e6b`。
|
||||
|
||||
## 发布:v3.4.18 套餐变更对比逐字段列出变动明细
|
||||
- 需求:套餐变更对比「将变动的地方详细列出来」(之前只给「新增/删除/变更/未变」粗粒度标记,不列具体字段)。
|
||||
- 改动仅在 `apps/admin-web/src/pages/StorePackageAuditsPage.tsx`:
|
||||
- 新增 `fieldChanges(live, proposed)`:逐字段比对 价格/套餐名称/菜品内容/可用时间/其他说明/图片,产出 `label+旧值→新值` 明细。
|
||||
- `diffPackages` 给「变更」行附 `changes`。
|
||||
- `PackageDetailCard` 在「待审核」卡片底部渲染「变更明细」块(旧值删除线 → 新值加粗,橙色块)。
|
||||
- 抽屉顶部加「新增 X · 删除 Y · 变更 Z · 未变 W」汇总条。
|
||||
- 提交 `bbd7e52`(feat(admin) 套餐变更对比逐字段列出变动明细),快进推送 dev_jacy/dev/main。
|
||||
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(无 schema 变更)。
|
||||
- 结果:构建通过、PM2 重启 admin-web 等、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `bbd7e52`(无精确 tag,记 tag=-,无害)。
|
||||
- 已知局限:配对按「套餐名称」(空则索引) 位置匹配;若套餐被**改名**,会误判为「删除旧名+新增新名」而非「变更」,故名称变更不会出现在逐字段明细里。
|
||||
|
||||
## 优化:套餐变更对比·逐字差异 + 文档并入 v3.4.17
|
||||
- 需求:变动明细要能**定位到文本中具体哪些字变了(逐字检查)**,且把开发计划文档合并到 v3.4.17 文档里(不再单独留 v3.4.18 文档)。
|
||||
- 代码改动(仍仅 `StorePackageAuditsPage.tsx`):
|
||||
- `diffText(a,b)`:LCS 动态规划 (`dp[i][j]`)+回溯,产出 `equal/delete/insert` 段落并合并相邻同类型;`O(|a|·|b|)`,字段长度可控。
|
||||
- `TextDiff({oldText,newText})`:两行渲染——「原:」红删线标被删字、「新:」绿标新增字。
|
||||
- `FieldChange` 增加 `kind: 'text'|'value'`:价格/图片走 `旧→新`;套餐名称/菜品内容/可用时间/其他说明走 `TextDiff` 逐字高亮。
|
||||
- `杜康好客-v3.4.17-门店套餐审核快捷入口.md` 新增第 F 节「套餐变更对比·逐字段/逐字明细」,并把原先 v3.4.18 的字段级说明并入,删去独立 v3.4.18 文档计划。
|
||||
- 提交 `ecd9973`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`。
|
||||
- 结果:构建通过、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `ecd9973`(tag=-,无害)。
|
||||
- 验证要点:开任一「变更」套餐的待审核卡片 → 底部「变更明细」→ 菜品/说明等文本以红删+绿增逐字呈现。
|
||||
|
||||
## 优化:h5-partner 门店详情·门店套餐「编辑套餐」按钮
|
||||
- 需求:按钮**缩小、靠最右侧、保持美观**。根因:`.partner-btn-outline` 是整行大按钮(`flex:1; height:48px; font-size:16px`),放在卡片标题右侧会被拉伸成大块。
|
||||
- 改动:`apps/h5-partner/src/styles.css` 新增 `.partner-btn-outline--compact`(`flex:none; height:30px; padding:0 14px; font-size:13px; font-weight:500; border-radius:var(--radius-sm); color:var(--color-primary)`,hover 用 `color-mix` 8% 底色);`StoreDetailPage.tsx:288` 的「编辑套餐」按钮加该类(父级 `space-between` 已保证贴右)。
|
||||
- 提交 `69e201f`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`。
|
||||
- 结果:构建通过、PM2 重启 h5-partner、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `69e201f`(tag=-,无害)。
|
||||
- 注意:`StoreDetailPage.tsx:311` 有个历史遗留 TS 错误(`status === 'CLOSED'` 类型无重叠),与本改动无关,未动。
|
||||
@@ -0,0 +1,70 @@
|
||||
# 2026-08-17 工作日志
|
||||
|
||||
## 杜康好客 v3.4.18 门店体验与登录态优化(含 F 项)
|
||||
|
||||
### 已实现并发布
|
||||
- **A** mini-user 门店座机脱敏:改 `apps/mini-user/src/lib/phone.ts` 的 `maskPhone` 座机分支,中间四位 `****`(保留区号+首2+末2)。
|
||||
- **B** 套餐详情图 1/5 计数 + 自动轮播:`ProductCarousel.tsx` detail 变体加 autoplay(3.5s) + 右下角 `1/N` 计数。
|
||||
- **C** 合伙人暂停禁止登录:`auth.service.ts` 三处非 ACTIVE 抛「该账号已暂停使用,请联系客服人员」;`h5-partner/LoginPage.tsx` 同步文案;复用 `DISABLED` 枚举,无迁移。
|
||||
- **D** 门店端提现/筛选栏留白对齐:`h5-shop/styles.css` 补 `--space-page`。
|
||||
- **E** 休息中核销→是否开启营业:`PhoneRedeemPage`/`RedeemConfirmPage` 弹「是否开启营业?」,确认调 `PUT /shop/store/status` 开张后继续。
|
||||
- **F(本次新增)** 门店套餐图片自适应完整显示:`ProductCarousel` 新增 `imageFit="adaptive"`(`mode="widthFix"` + 按真实比例动态算 Swiper 高度),`product-detail.css` 加 `.detail-carousel-wrap--adaptive` 覆盖固定 1:1 裁剪;仅 `store-package-detail` 使用。根因:原 `.detail-carousel-wrap` 固定 `aspect-ratio:1` + `overflow:hidden` + `aspectFill` 裁掉非正方形图。
|
||||
|
||||
### 发布状态
|
||||
- 文档:`杜康好客-v3.4.18-门店体验与登录态优化.md`(含 A–F 范围、实现位置、验收清单)。
|
||||
- git:本地 `dev_jacy` 提交 v3.4.18 各功能 + F 修复;已 `git push origin dev_jacy:main`(干净快进 `984dec7..18147e1`)。
|
||||
- 生产部署:后台运行 `deploy/deploy-prod.sh --host dukang-server`(/opt/dukang, main)。SSH 配置主机 `dukang-server` 即生产机;`deploy.env` 缺失,用 `--host` 覆盖。
|
||||
|
||||
### 注意
|
||||
- 本次 git 发布前曾遇 origin/main 比本地新、working tree 异常删除 503 文件(已 `reset --hard HEAD` 还原,提交完好);最终 fetch 后确认本地领先可快进,安全发布。
|
||||
- 四端 tsc 仅余项目既有环境类报错(CSS 导入/ImportMeta.env),本次改动文件无新增类型错误。
|
||||
|
||||
### F 项方案 A 改写(22:18)
|
||||
- 放弃手算高度逻辑(SelectorQuery + onLoad 测自然宽高),改用 swiper 原生 `autoHeight`:海报有多高轮播就有多高,无裁切。
|
||||
- `ProductCarousel.tsx`:删 `wrapWidth`/`imgHeights`/`handleImageLoad`/`swiperStyle`,adaptive 模式加 `autoHeight={isAdaptive}`,image 保持 `mode="widthFix"`。
|
||||
- `product-detail.css`:`.detail-carousel-wrap--adaptive` 覆盖(删 `aspect-ratio:1`/`overflow:hidden`),并给 `.detail-carousel`/`.detail-carousel-item`/`.detail-carousel-image` 在 adaptive 下 `height:auto`。
|
||||
- 已提交 `cd1e9ae` 并 `git push origin dev_jacy:main`(干净快进 `18147e1..cd1e9ae`)。
|
||||
- 重要:F 是原生微信小程序端改动,服务器部署只覆盖 H5-user 构建;真机小程序用户需经「微信开发者工具上传 + 微信审核」才能生效。
|
||||
|
||||
### E 项缺陷修复(22:46 测试不通过)
|
||||
- **根因**:门店非 OPEN 时,后端 `redeem.service.ts:loadOpenStoreAccount`(previewRedeem/confirmRedeem 共用)抛「门店未营业」,导致 `RedeemConfirmPage` 的 `preview` 拉取失败 → `preview` 为 null → 「确认核销」按钮 `disabled={!preview}` → 点击不了 → 开张弹窗永远不触发。手机号路径因弹窗在「发送验证码」时触发故未暴露。
|
||||
- **修复**:`RedeemConfirmPage` 把 preview 拉取抽成 `loadPreview()`;确认按钮改为 `disabled={loading || (!preview && !storeClosed)}`(闭店时仍可点→弹框);`openStoreAndContinue` 开张成功后 `await loadPreview()` 再 `doConfirm()`。
|
||||
- 弹窗文案对齐需求:「门店目前休息中无法核销,是否开启营业」(两页 modal title=门店休息中 / desc=该句;内联提示改为「门店目前休息中无法核销,开启营业后可继续」)。
|
||||
- 提交 `3ac008a`,`git push origin dev_jacy:main`(快进 `cd1e9ae..3ac008a`),生产重新部署中(deploy-prod.sh --host dukang-server)。
|
||||
- 注意:后端 `updateShopStatus` 对 CLOSED(永久关闭) 门店拒绝开张(抛「门店已永久关闭...」),属预期;需求针对 闭店/休息中(PAUSED) 场景,OK。
|
||||
|
||||
### E 项方案二次修正(23:00 用户纠正:前置到点击扫码)
|
||||
- **用户新要求**:门店 PAUSED 时,**点击「扫码核销」按钮那一刻就弹窗**,而不是扫完码进确认页才弹。
|
||||
- **改动**:`apps/h5-shop/src/pages/HomePage.tsx`(门店端首页)。
|
||||
- 复用 dashboard 已加载的 `status`(`dash?.store?.status`,L384 `status = String(store?.status||'')`)。
|
||||
- `handleScan()` 开头:`if (status !== 'OPEN') { setShowOpenModal(true); return; }` —— 不进入 isWechatEnv/scanQrCode 流程。
|
||||
- 新增 `openStoreAndContinue()`:`PUT /shop/store/status {OPEN}`,成功后 `loadDashboard()` 刷新状态(开关/扫码按钮同步),用户可再次点击扫码。
|
||||
- 弹窗复用全局 `.shop-redeem-modal` 样式(与确认页一致),文案「门店目前休息中无法核销,是否开启营业?」。
|
||||
- `RedeemConfirmPage` 的弹窗保留为带码直入确认页的兜底。
|
||||
- 提交 `c0d04ee`,`git push origin dev_jacy:main`(快进 `3ac008a..c0d04ee`),生产重新部署中(deploy-prod.sh --host dukang-server)。文档 E 节已同步更新方案描述。
|
||||
|
||||
### C 项缺陷修复(合伙人端 DISABLED 仍登录 - 微信路径)
|
||||
- **现象**:手机号(短信)路径已正确拦截(login 前先 `POST /partner/auth/phone/check` 校验非 ACTIVE 即拒),但**微信 OAuth 路径**报错被吞 → 用户无任何提示。
|
||||
- **根因**:`apps/h5-partner/src/contexts/PartnerSessionContext.tsx` L118 `catch { stripOAuthParamsFromLocation(); }` 把微信回调里 `processPartnerWechatOAuthCallback` 抛出的「该账号已暂停使用」错误吞掉。
|
||||
- **修复**:`catch (e) { stripOAuthParamsFromLocation(); toastError(e); }`(`toastError` 从 `@/lib/toast` 引入),让微信登录被暂停时同样弹「该账号已暂停使用,请联系客服人员」。
|
||||
- 提交 `5ca9aa0`,`git push origin dev_jacy:main`(快进 `c0d04ee..5ca9aa0`)。
|
||||
- 生产部署完成(deploy-prod.sh --host dukang-server, 38s):dukang-api 200、各 h5/partner 服务 online。mini-user(h5) 健康检查 FAIL 与本次无关(非 v3.4.18 改动范围,本次改 native 小程序 + h5-shop + h5-partner)。
|
||||
- **验收状态**:A/B/C/D/E/F 全部提交并发布生产;C 项的短信 + 微信两条路径拦截逻辑均已落地,建议真机/微信环境复测一次确认 toast 文案展示。
|
||||
|
||||
### C 项双拦截增强(23:37 用户要求“两个都要拦”)
|
||||
- **澄清**:city 合伙人有两个独立状态字段,别混:
|
||||
- `partner_account.status`(AccountStatus: ACTIVE/DISABLED)= **登录账号启用态**,feature 原拦截查的就是它(子账号自身)。
|
||||
- `partner_account.bindingStatus`(CityPartnerStatus: ACTIVE/PAUSED)= **城市合伙人绑定态**,主账号为准;子账号取父主账号的此字段。
|
||||
- **需求**:子账号 `status=DISABLED` 与 主账号 `bindingStatus=PAUSED` **都要拦**。
|
||||
- **实现**:`auth.service.ts` 新增统一闸门 `assertPartnerAccountActive(account)`(L313),同时拦上述两种;替换三处旧校验:`assertPartnerAccountByPhone`(L334) / `loginPartner`(L1086) / `loginPartnerWechat`(L1748)。
|
||||
- 自身非 ACTIVE → 「该账号已暂停使用,请联系客服人员」
|
||||
- 主账号 bindingStatus 非 ACTIVE → 「该合伙人合作已暂停,请联系客服人员」
|
||||
- **文档**:`杜康好客-v3.4.18-...md` C 节重写为双拦截;顶部范围表 + 验收清单同步。
|
||||
- 提交 `48d6900`,`git push origin dev_jacy:main`(快进 `5ca9aa0..48d6900`),生产部署完成(task I2mWSU, 37s):commit=48d6900、api 200、各服务 online。
|
||||
- **生产实测双拦截均生效**:
|
||||
- `status=DISABLED`(id=7 临时翻转→测试→已还原 ACTIVE):`phone/check` 返回 400「该账号已暂停使用,请联系客服人员」;还原后 201 ok:true。
|
||||
- `bindingStatus=PAUSED`(18049821889 等 3 个主账号):`phone/check` 返回 400「该合伙人合作已暂停,请联系客服人员」(修复前为 200 ok:true)。
|
||||
- 库内 status!=ACTIVE 数量=0;binding_status!=ACTIVE 数量=3(均为主账号 PAUSED)。
|
||||
- **遗留未提交文件(非本次改动,需用户决策)**:
|
||||
- `apps/h5-shop/src/styles.css`:`shop-records-status-chips` 由 `padding:0` → `padding-bottom:10px`(D 项后续微调,未提交)。
|
||||
- `apps/mini-user/src/styles/product-detail.css`:工作区版本把方案A(`aspect-ratio:auto`/`height:auto`)改回固定 4:3(`aspect-ratio:1.33`/`height:100%`),与已上线方案A矛盾,疑似另一轮实验性改动。已与用户确认前**暂不提交**。
|
||||
@@ -11,7 +11,7 @@
|
||||
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
|
||||
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
|
||||
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
|
||||
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
|
||||
| ~~V2 / preV1~~ | 历史参考(**已压缩**;V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
|
||||
|
||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
||||
|
||||
@@ -28,7 +28,7 @@ pnpm db:generate && pnpm db:validate
|
||||
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
|
||||
|
||||
# 开发(分终端)
|
||||
pnpm dev:api # http://localhost:3000/api/v1
|
||||
pnpm dev:api # http://localhost:3010/api/v1
|
||||
pnpm dev:user # :5173
|
||||
pnpm dev:shop # :5174
|
||||
pnpm dev:partner # :5175
|
||||
@@ -137,11 +137,14 @@ C 端门店仅 status=OPEN
|
||||
订单 Tab:待付款 | 已付款 | 已完成
|
||||
```
|
||||
|
||||
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
|
||||
**微信小程序 open-type**:`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`。
|
||||
|
||||
## 环境与发版
|
||||
|
||||
| 环境 | 分支 | 目录 | 端口 | 域名 |
|
||||
|------|------|------|------|------|
|
||||
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
|
||||
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — |
|
||||
| **staging 测试** | `dev` | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
|
||||
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||
- UI 共享组件优先 `@dukang/shared-ui`
|
||||
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
||||
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||
|
||||
## 新页面 workflow
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"@dukang/domain": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"antd": "^5.22.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^6.1.0",
|
||||
|
||||
@@ -46,9 +46,15 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||
import WecomBotsPage from './pages/WecomBotsPage';
|
||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||
import LlmConfigsPage from './pages/LlmConfigsPage';
|
||||
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
||||
import DevPlanVersionsPage from './pages/DevPlanVersionsPage';
|
||||
import DevPlanTasksPage from './pages/DevPlanTasksPage';
|
||||
import DevPlanSettingsPage from './pages/DevPlanSettingsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -75,9 +81,15 @@ export default function App() {
|
||||
<Route index element={<PromoCodeDetailPage />} />
|
||||
<Route path="users" element={<PromoCodeUsersPage />} />
|
||||
</Route>
|
||||
<Route path="/wecom-bots" element={<WecomBotsPage />} />
|
||||
<Route path="/wecom-bots" element={<Navigate to="/wecom/bots" replace />} />
|
||||
<Route path="/wecom/bots" element={<WecomBotsPage />} />
|
||||
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
|
||||
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
|
||||
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
||||
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
||||
<Route path="/dev-plan/versions" element={<DevPlanVersionsPage />} />
|
||||
<Route path="/dev-plan/tasks" element={<DevPlanTasksPage />} />
|
||||
<Route path="/dev-plan/settings" element={<DevPlanSettingsPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
@@ -125,6 +137,7 @@ export default function App() {
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -86,3 +86,21 @@ body,
|
||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-package-audit-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-package-audit-col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-package-audit-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -1,217 +1,381 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Form, Input, InputNumber, Space, Typography, message } from 'antd';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||
import PackageImagesUpload from './PackageImagesUpload';
|
||||
|
||||
type PackageRow = StorePackageItemDto;
|
||||
|
||||
export type AdminStorePackagesHandle = {
|
||||
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
|
||||
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
||||
};
|
||||
|
||||
function emptyRow(index = 0): PackageRow {
|
||||
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index };
|
||||
return {
|
||||
name: '',
|
||||
price: '0',
|
||||
dishes: '',
|
||||
usableTime: '',
|
||||
otherNotes: '',
|
||||
imageUrl: '',
|
||||
imageUrls: [],
|
||||
sortOrder: index,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
||||
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
|
||||
function AdminStorePackagesSection({ storeId }, ref) {
|
||||
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||
const itemsRef = useRef(items);
|
||||
const loadingRef = useRef(loading);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
|
||||
.then((data) => {
|
||||
useEffect(() => {
|
||||
itemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
loadingRef.current = loading;
|
||||
}, [loading]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setPendingRequest(null);
|
||||
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||
.then((data) => {
|
||||
setPendingRequest(data.pendingRequest ?? null);
|
||||
setItems(
|
||||
data.live?.length
|
||||
? data.live.map((p, i) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||
return {
|
||||
...p,
|
||||
price: String(p.price),
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
imageUrls,
|
||||
sortOrder: i,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
);
|
||||
})
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [storeId]);
|
||||
|
||||
// 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
|
||||
useEffect(() => {
|
||||
const onChanged = () => {
|
||||
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||
.then((data) => setPendingRequest(data.pendingRequest ?? null))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||
}, [storeId]);
|
||||
|
||||
function goAudit() {
|
||||
if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`);
|
||||
}
|
||||
|
||||
const pendingReminder =
|
||||
pendingRequest && pendingRequest.status === 'PENDING' ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="该门店有待审核套餐"
|
||||
description={
|
||||
<Space>
|
||||
<Button size="small" type="primary" onClick={goAudit}>
|
||||
审核
|
||||
</Button>
|
||||
<Button size="small" onClick={goAudit}>
|
||||
对比
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
const run = () => {
|
||||
setItems((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
||||
return next.length ? next : [];
|
||||
});
|
||||
setCollapsed((prev) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const i = Number(k);
|
||||
if (i < index) next[i] = v;
|
||||
else if (i > index) next[i - 1] = v;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
if (items.length === 1) {
|
||||
Modal.confirm({
|
||||
title: '清空门店套餐',
|
||||
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
onOk: run,
|
||||
});
|
||||
return;
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
function toggleCollapse(index: number) {
|
||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
}
|
||||
|
||||
async function save(opts?: { quiet?: boolean }) {
|
||||
const currentItems = itemsRef.current;
|
||||
const filled = currentItems
|
||||
.map((item, index) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls(item);
|
||||
return {
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || null,
|
||||
otherNotes: item.otherNotes?.trim() || null,
|
||||
imageUrl: imageUrls[0] ?? null,
|
||||
imageUrls,
|
||||
sortOrder: index,
|
||||
};
|
||||
})
|
||||
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
|
||||
.filter((item) => {
|
||||
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
|
||||
const hasImages = item.imageUrls.length > 0;
|
||||
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
|
||||
return hasText || hasImages || hasNonZeroPrice;
|
||||
});
|
||||
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const item = filled[i];
|
||||
if (!item.name) {
|
||||
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||
throw new Error('套餐校验失败');
|
||||
}
|
||||
if (!item.dishes) {
|
||||
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||
throw new Error('套餐校验失败');
|
||||
}
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||
throw new Error('套餐校验失败');
|
||||
}
|
||||
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
||||
message.warning(`第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`);
|
||||
throw new Error('套餐校验失败');
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||
}),
|
||||
});
|
||||
if (!opts?.quiet) message.success('套餐已保存并生效');
|
||||
setItems(
|
||||
data.live?.length
|
||||
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
|
||||
: [emptyRow()],
|
||||
? data.live.map((p, i) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||
return {
|
||||
...p,
|
||||
price: String(p.price),
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
imageUrls,
|
||||
sortOrder: i,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
);
|
||||
})
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [storeId]);
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||
setCollapsed((prev) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const i = Number(k);
|
||||
if (i < index) next[i] = v;
|
||||
else if (i > index) next[i - 1] = v;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCollapse(index: number) {
|
||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const filled = items
|
||||
.map((item, index) => ({
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || null,
|
||||
otherNotes: item.otherNotes?.trim() || null,
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((item) => item.name || item.dishes || item.price);
|
||||
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const item = filled[i];
|
||||
if (!item.name) {
|
||||
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||
return;
|
||||
}
|
||||
if (!item.dishes) {
|
||||
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||
return;
|
||||
}
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await request(`/admin/stores/${storeId}/packages`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||
}),
|
||||
});
|
||||
message.success('套餐已保存并生效');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
useImperativeHandle(ref, () => ({
|
||||
saveIfLoaded: async (opts) => {
|
||||
if (loadingRef.current) return { skipped: true };
|
||||
await save(opts);
|
||||
return { skipped: false };
|
||||
},
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||
}
|
||||
if (!items.length) {
|
||||
return (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
||||
{pendingReminder}
|
||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||
添加套餐
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||
保存套餐
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
||||
/>
|
||||
return (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
{pendingReminder}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
||||
/>
|
||||
|
||||
{items.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
onClick={() => toggleCollapse(index)}
|
||||
style={{ paddingLeft: 0, height: 'auto' }}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{displayName}
|
||||
</Typography.Title>
|
||||
</Button>
|
||||
{items.length > 1 ? (
|
||||
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||
删除
|
||||
{items.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
onClick={() => toggleCollapse(index)}
|
||||
style={{ paddingLeft: 0, height: 'auto' }}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{displayName}
|
||||
</Typography.Title>
|
||||
</Button>
|
||||
{items.length > 0 ? (
|
||||
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="如:套餐A"
|
||||
value={item.name}
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="元"
|
||||
placeholder="198"
|
||||
value={item.price === '' ? undefined : Number(item.price)}
|
||||
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
value={item.dishes}
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
||||
<PackageImagesUpload
|
||||
value={normalizeStorePackageImageUrls(item)}
|
||||
onChange={(imageUrls) =>
|
||||
updateAt(index, {
|
||||
imageUrls,
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="如:套餐A"
|
||||
value={item.name}
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||
添加套餐
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="元"
|
||||
placeholder="198"
|
||||
value={item.price === '' ? undefined : Number(item.price)}
|
||||
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
上传套餐图后请点右上角「保存修改」(会连同套餐一起保存),或点下方「保存套餐」。仅上传不保存,刷新会丢失。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
value={item.dishes}
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
||||
<Input
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||
添加套餐
|
||||
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||
保存套餐
|
||||
</Button>
|
||||
) : null}
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
修改后点击下方按钮保存,C 端将立即展示生效套餐。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存套餐
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
export default AdminStorePackagesSection;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button, Form, Space, Typography } from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import OssUpload from './OssUpload';
|
||||
import { Typography } from 'antd';
|
||||
import MultiImageUpload from './MultiImageUpload';
|
||||
|
||||
type Props = {
|
||||
name?: string;
|
||||
@@ -8,42 +7,35 @@ type Props = {
|
||||
bizType?: string;
|
||||
/** 最多可添加张数;不传则不限制 */
|
||||
maxCount?: number;
|
||||
/** Form.Item 注入 */
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 商品详情/轮播等多图列表。
|
||||
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
|
||||
*/
|
||||
export default function DetailImageUrlList({
|
||||
name = 'detailImageUrls',
|
||||
label,
|
||||
bizType = 'DETAIL',
|
||||
maxCount,
|
||||
value,
|
||||
onChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
{maxCount != null && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
最多 {maxCount} 张{label}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Form.List name={name}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
{(!maxCount || fields.length < maxCount) && (
|
||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
||||
添加{label}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{label}:支持批量上传{maxCount != null ? `,最多 ${maxCount} 张` : ''}
|
||||
</Typography.Text>
|
||||
<MultiImageUpload
|
||||
bizType={bizType}
|
||||
mediaType="IMAGE"
|
||||
maxCount={maxCount}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tip={`支持一次选择多张${label}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Image, Space, Typography, Upload, message } from 'antd';
|
||||
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
|
||||
type Props = {
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
bizType: string;
|
||||
mediaType?: OssMediaType;
|
||||
/** 最多张数;不传则不限制 */
|
||||
maxCount?: number;
|
||||
tip?: string;
|
||||
accept?: string;
|
||||
/** 上传按钮文案,默认「批量上传图片」 */
|
||||
buttonText?: string;
|
||||
};
|
||||
|
||||
function normalizeUrls(value?: string[]) {
|
||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isPdf(url: string) {
|
||||
return /\.pdf(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
|
||||
* Form.Item 直接绑定 string[]。
|
||||
*/
|
||||
export default function MultiImageUpload({
|
||||
value,
|
||||
onChange,
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
maxCount,
|
||||
tip,
|
||||
accept = 'image/*',
|
||||
buttonText = '批量上传图片',
|
||||
}: Props) {
|
||||
const urls = normalizeUrls(value);
|
||||
const urlsRef = useRef(urls);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const batchBuf = useRef<File[]>([]);
|
||||
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const uploadChain = useRef(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
urlsRef.current = urls;
|
||||
}, [urls]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (batchTimer.current) clearTimeout(batchTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
|
||||
const canAdd = remaining > 0;
|
||||
|
||||
async function uploadBatch(files: File[]) {
|
||||
const current = urlsRef.current;
|
||||
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
|
||||
const picked = files.slice(0, room);
|
||||
if (!picked.length) {
|
||||
message.warning(maxCount != null ? `最多 ${maxCount} 张` : '无法上传');
|
||||
return;
|
||||
}
|
||||
if (files.length > picked.length) {
|
||||
message.warning(`已达上限,仅上传前 ${picked.length} 张`);
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
const appended: string[] = [];
|
||||
let fail = 0;
|
||||
try {
|
||||
for (const file of picked) {
|
||||
try {
|
||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||
appended.push(result.url);
|
||||
} catch {
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
if (appended.length) {
|
||||
// 始终基于最新列表追加,避免并行上传互相覆盖
|
||||
const next = [...urlsRef.current, ...appended];
|
||||
urlsRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
message.success(`成功上传 ${appended.length} 张${fail ? `,失败 ${fail} 张` : ''}`);
|
||||
} else if (fail) {
|
||||
message.error('上传失败');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueUploadBatch(files: File[]) {
|
||||
uploadChain.current = uploadChain.current
|
||||
.then(() => uploadBatch(files))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function flushBatch() {
|
||||
if (batchTimer.current) {
|
||||
clearTimeout(batchTimer.current);
|
||||
batchTimer.current = null;
|
||||
}
|
||||
if (!batchBuf.current.length) return;
|
||||
const files = [...batchBuf.current];
|
||||
batchBuf.current = [];
|
||||
enqueueUploadBatch(files);
|
||||
}
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
batchBuf.current.push(file as File);
|
||||
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
|
||||
if (batchTimer.current) clearTimeout(batchTimer.current);
|
||||
batchTimer.current = setTimeout(() => {
|
||||
flushBatch();
|
||||
}, 80);
|
||||
return false;
|
||||
};
|
||||
|
||||
function removeAt(index: number) {
|
||||
const next = urlsRef.current.filter((_, i) => i !== index);
|
||||
urlsRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Typography.Text type="secondary">
|
||||
{tip ??
|
||||
(maxCount != null
|
||||
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
|
||||
: '支持一次选择多张批量上传')}
|
||||
{maxCount != null ? `(已选 ${urls.length}/${maxCount})` : urls.length ? `(已选 ${urls.length})` : ''}
|
||||
</Typography.Text>
|
||||
|
||||
{urls.length > 0 ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={12}>
|
||||
{urls.map((url, index) => (
|
||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
|
||||
{isPdf(url) ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 96,
|
||||
height: 96,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #f0f0f0',
|
||||
background: '#fafafa',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
|
||||
<span>PDF</span>
|
||||
</a>
|
||||
) : (
|
||||
<Image
|
||||
src={url}
|
||||
width={96}
|
||||
height={96}
|
||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeAt(index)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: 'rgba(255,255,255,0.85)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : null}
|
||||
|
||||
<Upload
|
||||
accept={accept}
|
||||
multiple
|
||||
showUploadList={false}
|
||||
beforeUpload={beforeUpload}
|
||||
disabled={uploading || !canAdd}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
|
||||
{canAdd ? buttonText : '已达上限'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Image,
|
||||
Space,
|
||||
Spin,
|
||||
Timeline,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
|
||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
orderId: string | null;
|
||||
orderNo?: string | null;
|
||||
orderStatus?: string | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function providerLabel(provider?: string | null, company?: string | null) {
|
||||
if (company) return company;
|
||||
if (!provider) return '—';
|
||||
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
|
||||
if (isXfxProviderCode(provider)) return '小飞侠';
|
||||
return provider;
|
||||
}
|
||||
|
||||
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const ta = new Date(a.createTime).getTime();
|
||||
const tb = new Date(b.createTime).getTime();
|
||||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||||
if (Number.isNaN(ta)) return 1;
|
||||
if (Number.isNaN(tb)) return -1;
|
||||
return ta - tb;
|
||||
});
|
||||
}
|
||||
|
||||
export default function OrderTrackDrawer({
|
||||
open,
|
||||
orderId,
|
||||
orderNo,
|
||||
orderStatus,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [track, setTrack] = useState<OrderTrackDto | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!orderId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
|
||||
setTrack(data);
|
||||
} catch (e) {
|
||||
setTrack(null);
|
||||
message.error(e instanceof Error ? e.message : '加载路由失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !orderId) {
|
||||
setTrack(null);
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
}, [open, orderId, load]);
|
||||
|
||||
const nodes = useMemo(
|
||||
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
|
||||
[track?.nodes],
|
||||
);
|
||||
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
||||
orderStatus || '',
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="物流路由"
|
||||
width={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
extra={
|
||||
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{(orderNo || track?.trackingNo || track?.provider) && (
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
|
||||
<Descriptions.Item label="配送方式">
|
||||
{providerLabel(track?.provider, track?.logisticsCompany)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
|
||||
{track?.manualQueryUrl ? (
|
||||
<Descriptions.Item label="物流查询">
|
||||
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||
打开物流查询
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{track?.estimatedArrival?.arriveTime ? (
|
||||
<Descriptions.Item label="预计送达">
|
||||
{fmtTime(track.estimatedArrival.arriveTime)}
|
||||
{track.estimatedArrival.siteName
|
||||
? `(${track.estimatedArrival.siteName})`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
签收照片
|
||||
</Typography.Title>
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap>
|
||||
{track.signPhotoUrls.map((url) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={96}
|
||||
height={96}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
物流动态
|
||||
</Typography.Title>
|
||||
{nodes.length > 0 ? (
|
||||
<Timeline
|
||||
items={nodes.map((node, index) => {
|
||||
const isLatest = index === nodes.length - 1;
|
||||
return {
|
||||
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
|
||||
children: (
|
||||
<div>
|
||||
{node.statusName ? (
|
||||
<Typography.Text strong style={{ display: 'block' }}>
|
||||
{node.statusName}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
|
||||
{node.trackInfo || '—'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{fmtTime(node.createTime)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
track?.manualQueryUrl
|
||||
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||
: '暂无路由信息,请稍后刷新'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Image, Input, Space, Upload, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
||||
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||
|
||||
@@ -15,7 +15,13 @@ type OssUploadProps = {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
function isImageUrl(url: string) {
|
||||
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function isPdfUrl(url: string) {
|
||||
return /\.pdf(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
export default function OssUpload({
|
||||
value,
|
||||
@@ -24,22 +30,16 @@ export default function OssUpload({
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
accept,
|
||||
maxSizeMb = DEFAULT_MAX_MB,
|
||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||
}: OssUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||
|
||||
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||
const raw = file as File;
|
||||
if (raw.size > maxSizeMb * 1024 * 1024) {
|
||||
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||
@@ -56,6 +56,57 @@ export default function OssUpload({
|
||||
}
|
||||
};
|
||||
|
||||
const filePreview =
|
||||
value && mediaType === 'FILE' ? (
|
||||
isImageUrl(value) ? (
|
||||
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
) : isPdfUrl(value) ? (
|
||||
<Space direction="vertical" size={8}>
|
||||
<div
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 4,
|
||||
border: '1px solid #f0f0f0',
|
||||
background: '#fafafa',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
color: '#cf1322',
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined style={{ fontSize: 36 }} />
|
||||
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF 合同</span>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
|
||||
预览
|
||||
</Button>
|
||||
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
|
||||
新窗口打开
|
||||
</Button>
|
||||
</Space>
|
||||
<Modal
|
||||
title="签约合同预览"
|
||||
open={pdfPreviewOpen}
|
||||
onCancel={() => setPdfPreviewOpen(false)}
|
||||
footer={null}
|
||||
width="90vw"
|
||||
styles={{ body: { height: '75vh', padding: 0 } }}
|
||||
destroyOnClose
|
||||
>
|
||||
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
|
||||
</Modal>
|
||||
</Space>
|
||||
) : (
|
||||
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
|
||||
打开已上传文件
|
||||
</Button>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{value && mediaType === 'IMAGE' && (
|
||||
@@ -64,6 +115,7 @@ export default function OssUpload({
|
||||
{value && mediaType === 'VIDEO' && (
|
||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||
)}
|
||||
{filePreview}
|
||||
<Space wrap>
|
||||
<Upload
|
||||
accept={resolvedAccept}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import MultiImageUpload from './MultiImageUpload';
|
||||
|
||||
type Props = {
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
};
|
||||
|
||||
/** 套餐多图:批量上传,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
|
||||
export default function PackageImagesUpload({ value, onChange }: Props) {
|
||||
return (
|
||||
<MultiImageUpload
|
||||
bizType="STORE_PACKAGE"
|
||||
mediaType="IMAGE"
|
||||
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Descriptions } from 'antd';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
function maskPhone(phone: string | null | undefined) {
|
||||
if (!phone || phone.length < 7) return phone ?? '—';
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
detail: Record<string, unknown>;
|
||||
/** 列表页已脱敏展示时可设为 false,详情抽屉展示完整手机号 */
|
||||
maskUserPhone?: boolean;
|
||||
};
|
||||
|
||||
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
||||
const user = detail.user as
|
||||
| { userNo?: string; nickname?: string | null; phone?: string | null }
|
||||
| undefined;
|
||||
const store = detail.store as
|
||||
| {
|
||||
name?: string;
|
||||
cityName?: string;
|
||||
address?: string;
|
||||
partnerAccount?: { companyName?: string };
|
||||
}
|
||||
| undefined;
|
||||
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
|
||||
const channel = detail.channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||
|
||||
return (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(detail.redeemNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="方式">{REDEEM_CHANNEL_LABELS[channel as RedeemChannel]}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">¥{Number(detail.amount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户手机">
|
||||
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{store?.name ?? '—'}
|
||||
{store?.cityName ? `(${store.cityName})` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
|
||||
<Descriptions.Item label="券分摊">
|
||||
{(
|
||||
detail.allocations as Array<{
|
||||
couponNo?: string;
|
||||
orderNo?: string | null;
|
||||
amount?: number;
|
||||
}>
|
||||
)
|
||||
.map((a, idx) => {
|
||||
const role = idx === 0 ? '主' : '次';
|
||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||
}`;
|
||||
})
|
||||
.join(';')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{detail.payout ? (
|
||||
<Descriptions.Item label="门店结算单">
|
||||
¥{Number((detail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||
{' / '}
|
||||
{String((detail.payout as { status?: string }).status ?? '—')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{detail.rating ? (
|
||||
<Descriptions.Item label="评价">
|
||||
服务 {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||
{(detail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||
import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
RobotOutlined,
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
LockOutlined,
|
||||
SettingOutlined,
|
||||
AccountBookOutlined,
|
||||
ProjectOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
@@ -42,7 +44,15 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
},
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
|
||||
{
|
||||
key: 'wecom-group',
|
||||
icon: <TeamOutlined />,
|
||||
label: '企微机器人',
|
||||
children: [
|
||||
{ key: '/wecom/bots', label: '智能机器人' },
|
||||
{ key: '/wecom/pushes', label: '消息推送' },
|
||||
],
|
||||
},
|
||||
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||
{
|
||||
@@ -110,6 +120,16 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/tickets/support', label: '技术支持' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'dev-plan-group',
|
||||
icon: <ProjectOutlined />,
|
||||
label: '开发计划',
|
||||
children: [
|
||||
{ key: '/dev-plan/versions', label: '版本列表' },
|
||||
{ key: '/dev-plan/tasks', label: '任务列表' },
|
||||
{ key: '/dev-plan/settings', label: '开发设置' },
|
||||
],
|
||||
},
|
||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||
{
|
||||
@@ -123,9 +143,11 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
||||
{ key: '/logs/third-party', label: '第三方日志' },
|
||||
{ key: '/logs/domain-events', label: '领域事件' },
|
||||
{ key: '/logs/wecom-bots', label: '智能机器人日志' },
|
||||
],
|
||||
},
|
||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
@@ -134,6 +156,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
if (key === 'tickets-group') {
|
||||
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||
}
|
||||
if (key === 'dev-plan-group') {
|
||||
return permissionKeys.includes('dev_plan');
|
||||
}
|
||||
const map: Record<string, string | 'system_settings_any'> = {
|
||||
'/': 'dashboard',
|
||||
'/users': 'users',
|
||||
@@ -143,7 +168,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/product-detail-templates': 'products',
|
||||
'/orders': 'orders',
|
||||
'/promo-codes': 'promo_codes',
|
||||
'/wecom-bots': 'wecom_bots',
|
||||
'/wecom/bots': 'wecom_bots',
|
||||
'/wecom/pushes': 'wecom_bots',
|
||||
'wecom-group': 'wecom_bots',
|
||||
'/llm-configs': 'llm_configs',
|
||||
'/knowledge-bases': 'knowledge_bases',
|
||||
'stores-group': 'stores',
|
||||
@@ -175,16 +202,22 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/deliveries/xiaofeixia': 'deliveries',
|
||||
'/tickets': 'tickets',
|
||||
'/tickets/support': 'tech_support',
|
||||
'dev-plan-group': 'dev_plan',
|
||||
'/dev-plan/versions': 'dev_plan',
|
||||
'/dev-plan/tasks': 'dev_plan',
|
||||
'/dev-plan/settings': 'dev_plan',
|
||||
'/invoices': 'invoices',
|
||||
'/resources': 'resources',
|
||||
'logs-group': 'logs',
|
||||
'/logs/users': 'logs',
|
||||
'/logs/wecom-bots': 'logs',
|
||||
'/logs/stores': 'logs',
|
||||
'/logs/partners': 'logs',
|
||||
'/logs/hq': 'logs',
|
||||
'/logs/third-party': 'logs',
|
||||
'/logs/domain-events': 'logs',
|
||||
'/hq-permissions': 'hq_permissions',
|
||||
'/test-whitelist': 'test_whitelist',
|
||||
'/system-settings': 'system_settings_any',
|
||||
'/hq-accounts': 'hq_accounts',
|
||||
};
|
||||
@@ -211,6 +244,31 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
|
||||
.filter(Boolean) as MenuProps['items'];
|
||||
}
|
||||
|
||||
function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
|
||||
if (!items) return items;
|
||||
return items.map((item) => {
|
||||
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||
if ('children' in item && Array.isArray(item.children)) {
|
||||
return {
|
||||
...item,
|
||||
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount),
|
||||
} as MenuItem;
|
||||
}
|
||||
if (String(item.key) === '/store-package-audits') {
|
||||
return {
|
||||
...item,
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
套餐审核
|
||||
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
||||
</span>
|
||||
),
|
||||
} as MenuItem;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
||||
|
||||
export default function AdminLayout() {
|
||||
@@ -218,11 +276,28 @@ export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [packagePendingCount, setPackagePendingCount] = useState(0);
|
||||
|
||||
function refreshPackagePendingCount() {
|
||||
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
|
||||
.then((data) => setPackagePendingCount(data.pendingCount ?? 0))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPackagePendingCount();
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChanged = () => refreshPackagePendingCount();
|
||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||
}, [location.pathname]);
|
||||
@@ -241,10 +316,12 @@ export default function AdminLayout() {
|
||||
: location.pathname;
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
if (!profile) return MENU_ITEMS;
|
||||
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
||||
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||
}, [profile]);
|
||||
const base =
|
||||
!profile || profile.adminRole === 'SUPER_ADMIN'
|
||||
? MENU_ITEMS
|
||||
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||
return attachPackageAuditBadge(base, packagePendingCount);
|
||||
}, [profile, packagePendingCount]);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
|
||||
|
||||
export function notifyPackageAuditChanged() {
|
||||
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
|
||||
}
|
||||
@@ -167,6 +167,13 @@ export type AdminUserRow = {
|
||||
sourceLabel: string | null;
|
||||
createdAt: string;
|
||||
orderCount: number;
|
||||
isTest?: boolean;
|
||||
/** 好客权益·累计获得(含已使用,不含退款作废) */
|
||||
benefitTotalAmount?: number;
|
||||
/** 好客权益·已使用(已核销) */
|
||||
benefitUsedAmount?: number;
|
||||
/** 好客权益·剩余未使用 */
|
||||
benefitBalance?: number;
|
||||
};
|
||||
|
||||
export type AdminOrderItem = {
|
||||
@@ -199,6 +206,7 @@ export type AdminOrderRow = {
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
isTest?: boolean;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
|
||||
@@ -140,10 +140,29 @@ export function fmtTime(v?: string | null) {
|
||||
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
||||
}
|
||||
|
||||
/** 手机号脱敏展示:138****5678;空值返回 — */
|
||||
/** 联系电话脱敏:手机 138****5678;座机 0379-****888;空值返回 — */
|
||||
export function maskPhone(phone?: string | null): string {
|
||||
const raw = String(phone ?? '').trim();
|
||||
const raw = String(phone ?? '').trim().replace(/\s+/g, '');
|
||||
if (!raw) return '—';
|
||||
if (/^1[3-9]\d{9}$/.test(raw)) {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||
}
|
||||
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
|
||||
const extMatch = raw.match(/-(\d{1,6})$/);
|
||||
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
|
||||
const ext = hasExt ? extMatch![1] : '';
|
||||
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
|
||||
const digits = main.replace(/\D/g, '');
|
||||
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
|
||||
const area = digits.slice(0, areaLen);
|
||||
const local = digits.slice(areaLen);
|
||||
const keepTail = Math.min(4, Math.max(2, local.length - 4));
|
||||
const maskedLocal =
|
||||
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
|
||||
const joiner = main.includes('-') ? '-' : '';
|
||||
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
|
||||
}
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
if (digits.length >= 11) {
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||
|
||||
export type StoreCreateForm = {
|
||||
partnerAccountId: string;
|
||||
cityId: string;
|
||||
@@ -15,6 +17,8 @@ export type StoreCreateForm = {
|
||||
longitude?: number | null;
|
||||
intro?: string;
|
||||
benefitUsageRule?: string;
|
||||
/** 对外联系电话(店长);可与登录号不同,支持座机 */
|
||||
contactPhone?: string;
|
||||
openTime?: string;
|
||||
closeTime?: string;
|
||||
openTime2?: string;
|
||||
@@ -22,11 +26,13 @@ export type StoreCreateForm = {
|
||||
avgPrice?: number | null;
|
||||
coverUrl?: string;
|
||||
envPhotoUrls?: string[];
|
||||
contractUrl?: string;
|
||||
/** 签约合同,支持多张照片 / PDF */
|
||||
contractUrls?: string[];
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
settlementRate?: number;
|
||||
sortOrder?: number;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
};
|
||||
@@ -57,6 +63,7 @@ export function validateStoreCreateStep1(
|
||||
| 'openTime2'
|
||||
| 'closeTime2'
|
||||
| 'avgPrice'
|
||||
| 'contactPhone'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||
@@ -66,6 +73,8 @@ export function validateStoreCreateStep1(
|
||||
if (!form.name?.trim()) return '请填写门店名称';
|
||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||
const contactPhone = form.contactPhone?.trim();
|
||||
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
|
||||
if (!form.address?.trim()) return '请填写详细地址';
|
||||
|
||||
const openTime = String(form.openTime || '').trim();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
|
||||
import {
|
||||
compressImageFileIfNeeded,
|
||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||
formatOssMaxSizeMb,
|
||||
} from '@dukang/shared-ui/compressImage';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
@@ -9,14 +14,24 @@ export type UploadFileResult = {
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
||||
if (mediaType !== 'IMAGE') return file;
|
||||
const compressed = await compressImageFileIfNeeded(file);
|
||||
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
||||
}
|
||||
return compressed;
|
||||
}
|
||||
|
||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||
export async function uploadFileToOss(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
const prepared = await prepareUploadFile(file, mediaType);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', prepared);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
@@ -242,7 +243,8 @@ export default function BenefitCouponsPage() {
|
||||
|
||||
<Drawer
|
||||
title="权益券详情"
|
||||
width={720}
|
||||
width={980}
|
||||
styles={{ body: { paddingBottom: 24 } }}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
@@ -310,14 +312,15 @@ export default function BenefitCouponsPage() {
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 70,
|
||||
width: 72,
|
||||
render: (v: string | undefined) =>
|
||||
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||
},
|
||||
@@ -331,31 +334,31 @@ export default function BenefitCouponsPage() {
|
||||
{
|
||||
title: '本券分摊',
|
||||
dataIndex: 'couponAmount',
|
||||
width: 95,
|
||||
width: 96,
|
||||
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
||||
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '核销总额',
|
||||
dataIndex: 'amount',
|
||||
width: 90,
|
||||
width: 96,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '结算额',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 90,
|
||||
width: 96,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
width: 148,
|
||||
render: (v: string) => fmtTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 70,
|
||||
width: 64,
|
||||
render: (_: unknown, row: CouponRedeemRecord) => (
|
||||
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||
详情
|
||||
@@ -374,7 +377,7 @@ export default function BenefitCouponsPage() {
|
||||
|
||||
<Drawer
|
||||
title="核销单详情"
|
||||
width={520}
|
||||
width={640}
|
||||
open={redeemDrawerOpen}
|
||||
onClose={() => {
|
||||
setRedeemDrawerOpen(false);
|
||||
@@ -385,79 +388,7 @@ export default function BenefitCouponsPage() {
|
||||
{redeemDetailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : redeemDetail ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">
|
||||
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">
|
||||
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
|
||||
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
|
||||
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
|
||||
? `(${(redeemDetail.store as { cityName?: string }).cityName})`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址">
|
||||
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">
|
||||
{String(
|
||||
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
|
||||
?.partnerAccount?.companyName ?? '—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="权益券号">
|
||||
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">
|
||||
{String(
|
||||
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
|
||||
'—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{Array.isArray(redeemDetail.allocations) &&
|
||||
(redeemDetail.allocations as unknown[]).length > 0 ? (
|
||||
<Descriptions.Item label="券分摊">
|
||||
{(
|
||||
redeemDetail.allocations as Array<{
|
||||
couponNo?: string;
|
||||
orderNo?: string | null;
|
||||
amount?: number;
|
||||
sortOrder?: number;
|
||||
}>
|
||||
)
|
||||
.map((a, idx) => {
|
||||
const role = idx === 0 ? '主' : '次';
|
||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||
}`;
|
||||
})
|
||||
.join(';')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.payout ? (
|
||||
<Descriptions.Item label="门店结算单">
|
||||
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||
{' / '}
|
||||
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.rating ? (
|
||||
<Descriptions.Item label="评价">
|
||||
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<RedeemRecordDetailDescriptions detail={redeemDetail} />
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,7 @@ type Row = {
|
||||
accountCount: number;
|
||||
subAccounts?: SubRow[];
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.companyName) qs.set('companyName', String(filters.companyName));
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||
{
|
||||
title: '主账号姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
|
||||
@@ -3,13 +3,26 @@ import {
|
||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
import { request } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
|
||||
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
|
||||
id: string;
|
||||
orderId: string;
|
||||
provider: string;
|
||||
trackingNo: string | null;
|
||||
providerOrderNo: string | null;
|
||||
updatedAt: string;
|
||||
order?: {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
deliveryType: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function DeliveriesPage() {
|
||||
@@ -29,6 +42,22 @@ export default function DeliveriesPage() {
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
|
||||
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
|
||||
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
|
||||
|
||||
function openTrack(row: Row) {
|
||||
const orderId = row.orderId || row.order?.id;
|
||||
if (!orderId) {
|
||||
message.warning('缺少关联订单,无法查询路由');
|
||||
return;
|
||||
}
|
||||
setTrackOrderId(orderId);
|
||||
setTrackOrderNo(row.order?.orderNo ?? null);
|
||||
setTrackOrderStatus(row.order?.status ?? null);
|
||||
setTrackOpen(true);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||
@@ -39,14 +68,17 @@ export default function DeliveriesPage() {
|
||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作', width: 140,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -87,9 +119,22 @@ export default function DeliveriesPage() {
|
||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||
</Form>
|
||||
<Button
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => openTrack(detail)}
|
||||
>
|
||||
查看路由
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<OrderTrackDrawer
|
||||
open={trackOpen}
|
||||
orderId={trackOrderId}
|
||||
orderNo={trackOrderNo}
|
||||
orderStatus={trackOrderStatus}
|
||||
onClose={() => setTrackOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Collapse, Form, Input, Select, Typography, message } from 'antd';
|
||||
import type {
|
||||
DevPlanSettingsDto,
|
||||
KnowledgeBaseOptionDto,
|
||||
LlmApiConfigOptionDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
export default function DevPlanSettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
|
||||
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
|
||||
const [settingsForm] = Form.useForm();
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [s, llm, kb] = await Promise.all([
|
||||
request<DevPlanSettingsDto>('/admin/dev-plan/settings'),
|
||||
request<LlmApiConfigOptionDto[]>('/admin/llm-configs/options'),
|
||||
request<KnowledgeBaseOptionDto[]>('/admin/knowledge-bases/options'),
|
||||
]);
|
||||
setLlmOptions(llm);
|
||||
setKbOptions(kb);
|
||||
settingsForm.setFieldsValue({
|
||||
reviewAssistantLlmConfigId: s.reviewAssistantLlmConfigId,
|
||||
reviewAssistantKnowledgeBaseId: s.reviewAssistantKnowledgeBaseId,
|
||||
reviewAssistantPrompt: s.reviewAssistantPrompt,
|
||||
});
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
async function saveSettings() {
|
||||
const values = await settingsForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await request<DevPlanSettingsDto>('/admin/dev-plan/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('设置已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>开发计划 · 开发设置</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
任务评审派发 Webhook 已迁移至「企微机器人 → 消息推送」,勾选「开发任务评审派发」条件即可。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Collapse
|
||||
defaultActiveKey={['review-ai']}
|
||||
style={{ marginTop: 16 }}
|
||||
items={[
|
||||
{
|
||||
key: 'review-ai',
|
||||
label: '本地审核 AI 助手',
|
||||
children: (
|
||||
<Form form={settingsForm} layout="vertical" disabled={loading}>
|
||||
<Typography.Paragraph type="secondary">
|
||||
仅用于技术支持批量预审(只读知识库 + LLM),preview 阶段不写库。
|
||||
</Typography.Paragraph>
|
||||
<Form.Item name="reviewAssistantLlmConfigId" label="语言模型">
|
||||
<Select
|
||||
allowClear
|
||||
options={llmOptions.map((o) => ({ value: o.id, label: o.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="reviewAssistantKnowledgeBaseId" label="知识库">
|
||||
<Select
|
||||
allowClear
|
||||
options={kbOptions.map((o) => ({ value: o.id, label: o.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="reviewAssistantPrompt" label="审核提示词">
|
||||
<Input.TextArea rows={5} />
|
||||
</Form.Item>
|
||||
<Button type="primary" loading={saving} onClick={() => void saveSettings()}>
|
||||
保存
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
DEV_PLAN_TASK_STATUS_LABELS,
|
||||
DEV_PLAN_TASK_TYPE_LABELS,
|
||||
SUPPORT_TICKET_STATUS_LABELS,
|
||||
type DevPlanTaskDto,
|
||||
type DevPlanTaskStatusDto,
|
||||
type DevPlanTaskTypeDto,
|
||||
type DevPlanVersionDto,
|
||||
type SupportTicketDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
||||
value: v,
|
||||
label: DEV_PLAN_TASK_TYPE_LABELS[v],
|
||||
}));
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_TASK_STATUS_LABELS) as DevPlanTaskStatusDto[]).map(
|
||||
(v) => ({ value: v, label: DEV_PLAN_TASK_STATUS_LABELS[v] }),
|
||||
);
|
||||
|
||||
const STATUS_COLOR: Record<DevPlanTaskStatusDto, string> = {
|
||||
TODO: 'default',
|
||||
DEVELOPED: 'blue',
|
||||
RELEASED: 'green',
|
||||
};
|
||||
|
||||
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
|
||||
|
||||
export default function DevPlanTasksPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||
const [batchEditOpen, setBatchEditOpen] = useState(false);
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
||||
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
|
||||
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dispatching, setDispatching] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
|
||||
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchEditOpen) return;
|
||||
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
||||
.then((res) => setVersions(res.items ?? []))
|
||||
.catch(() => setVersions([]));
|
||||
}, [batchEditOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || editing) return;
|
||||
request<{ items: SupportTicketDto[] }>('/admin/support-tickets?pageSize=100')
|
||||
.then((res) => setSupportTickets(res.items ?? []))
|
||||
.catch(() => setSupportTickets([]));
|
||||
}, [modalOpen, editing]);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
|
||||
'/admin/dev-plan/tasks',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.type) qs.set('type', filters.type);
|
||||
if (filters.keyword) qs.set('keyword', filters.keyword);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: DevPlanTaskDto) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({ content: row.content, type: row.type, status: row.status });
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await request('/admin/dev-plan/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: values.content,
|
||||
type: values.type,
|
||||
supportTicketId: values.supportTicketId || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await request(`/admin/dev-plan/tasks/${id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function openDispatch() {
|
||||
dispatchForm.setFieldsValue({
|
||||
supplement: DEFAULT_DISPATCH_SUPPLEMENT,
|
||||
});
|
||||
setDispatchOpen(true);
|
||||
}
|
||||
|
||||
function openBatchEdit() {
|
||||
batchForm.setFieldsValue({ status: undefined, versionId: undefined });
|
||||
setBatchEditOpen(true);
|
||||
}
|
||||
|
||||
async function submitBatchEdit() {
|
||||
const values = await batchForm.validateFields();
|
||||
if (!values.status && !values.versionId) {
|
||||
message.warning('请至少选择状态或关联版本');
|
||||
return;
|
||||
}
|
||||
setBatchSaving(true);
|
||||
try {
|
||||
await request('/admin/dev-plan/tasks/batch-update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
taskIds: selectedRowKeys,
|
||||
status: values.status || undefined,
|
||||
versionId: values.versionId || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('批量更新成功');
|
||||
setBatchEditOpen(false);
|
||||
setSelectedRowKeys([]);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '批量更新失败');
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDispatch() {
|
||||
const values = await dispatchForm.validateFields();
|
||||
setDispatching(true);
|
||||
try {
|
||||
await request('/admin/dev-plan/tasks/dispatch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
taskIds: selectedRowKeys,
|
||||
supplement: values.supplement?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已派发到企微群');
|
||||
setDispatchOpen(false);
|
||||
setSelectedRowKeys([]);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '派发失败');
|
||||
} finally {
|
||||
setDispatching(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DevPlanTaskDto> = [
|
||||
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
width: 80,
|
||||
render: (t: DevPlanTaskTypeDto) => DEV_PLAN_TASK_TYPE_LABELS[t],
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: DevPlanTaskStatusDto) => (
|
||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
||||
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
开发计划 · 任务列表
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
|
||||
批量编辑
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
|
||||
评审
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建任务
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select allowClear style={{ width: 100 }} options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="任务号/内容" style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑任务' : '新建任务'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void save()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="content" label="任务内容" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={4} maxLength={4000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
{editing ? (
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="可选:关联工单"
|
||||
options={supportTickets.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.ticketNo} · ${SUPPORT_TICKET_STATUS_LABELS[t.status]} · ${t.title}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="评审派发"
|
||||
open={dispatchOpen}
|
||||
onCancel={() => setDispatchOpen(false)}
|
||||
onOk={() => void submitDispatch()}
|
||||
confirmLoading={dispatching}
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
已选 {selectedRowKeys.length} 条任务,将通过「企微机器人 → 消息推送」中勾选「开发任务评审派发」的实例推送到开发群(@
|
||||
userid 由各推送配置)。
|
||||
</Typography.Paragraph>
|
||||
<Form form={dispatchForm} layout="vertical">
|
||||
<Form.Item name="supplement" label="补充说明">
|
||||
<Input.TextArea rows={3} placeholder="默认任务说明;@ 成员由消息推送配置决定" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量编辑任务"
|
||||
open={batchEditOpen}
|
||||
onCancel={() => setBatchEditOpen(false)}
|
||||
onOk={() => void submitBatchEdit()}
|
||||
confirmLoading={batchSaving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Paragraph type="secondary">已选 {selectedRowKeys.length} 条任务</Typography.Paragraph>
|
||||
<Form form={batchForm} layout="vertical">
|
||||
<Form.Item name="status" label="状态(不修改请留空)">
|
||||
<Select allowClear placeholder="不修改" options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="versionId" label="关联版本(追加关联,不修改请留空)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="不修改"
|
||||
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
DEV_PLAN_VERSION_STATUS_LABELS,
|
||||
type DevPlanTaskDto,
|
||||
type DevPlanVersionDto,
|
||||
type DevPlanVersionStatusDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
|
||||
(v) => ({ value: v, label: DEV_PLAN_VERSION_STATUS_LABELS[v] }),
|
||||
);
|
||||
|
||||
const STATUS_COLOR: Record<DevPlanVersionStatusDto, string> = {
|
||||
PENDING: 'default',
|
||||
IN_PROGRESS: 'processing',
|
||||
TESTING: 'purple',
|
||||
RELEASED: 'green',
|
||||
};
|
||||
|
||||
function formatDuration(minutes?: number | null) {
|
||||
if (minutes == null) return '—';
|
||||
if (minutes < 60) return `${minutes} 分钟`;
|
||||
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`;
|
||||
}
|
||||
|
||||
export default function DevPlanVersionsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<DevPlanVersionDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [allTasks, setAllTasks] = useState<DevPlanTaskDto[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<DevPlanVersionDto>('/admin/dev-plan/versions', () => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<{ items: DevPlanTaskDto[] }>('/admin/dev-plan/tasks?page=1&pageSize=500')
|
||||
.then((res) => setAllTasks(res.items ?? []))
|
||||
.catch(() => setAllTasks([]));
|
||||
}, []);
|
||||
|
||||
async function openEdit(row: DevPlanVersionDto) {
|
||||
const detail = await request<DevPlanVersionDto>(`/admin/dev-plan/versions/${row.id}`);
|
||||
setEditing(detail);
|
||||
form.setFieldsValue({
|
||||
versionNo: detail.versionNo,
|
||||
content: detail.content ?? '',
|
||||
status: detail.status,
|
||||
taskIds: detail.taskIds ?? [],
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ versionNo: '', content: '', status: 'PENDING', taskIds: [] });
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
versionNo: values.versionNo.trim(),
|
||||
content: values.content?.trim() || undefined,
|
||||
status: values.status,
|
||||
taskIds: values.taskIds ?? [],
|
||||
};
|
||||
if (editing) {
|
||||
await request(`/admin/dev-plan/versions/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await request('/admin/dev-plan/versions', { method: 'POST', body: JSON.stringify(body) });
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await request(`/admin/dev-plan/versions/${id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DevPlanVersionDto> = [
|
||||
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: DevPlanVersionStatusDto) => (
|
||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||
{ title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||
{ title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||
{
|
||||
title: '用时',
|
||||
dataIndex: 'durationMinutes',
|
||||
width: 100,
|
||||
render: (v) => formatDuration(v),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => void openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除?(不删除关联任务)" onConfirm={() => void remove(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
开发计划 · 版本列表
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建版本
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑版本' : '新建版本'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void save()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="versionNo" label="版本号" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 v3.4.11" maxLength={32} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="内容">
|
||||
<Input.TextArea placeholder="版本描述 / 更新日志" rows={3} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="taskIds" label="关联任务">
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={allTasks.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.taskNo} · ${t.content.slice(0, 40)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
KnowledgeBaseDto,
|
||||
KnowledgeDocumentDetailDto,
|
||||
KnowledgeDocumentDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
@@ -49,6 +50,10 @@ export default function KnowledgeBasesPage() {
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [docModalOpen, setDocModalOpen] = useState(false);
|
||||
const [docSaving, setDocSaving] = useState(false);
|
||||
const [editingDoc, setEditingDoc] = useState<KnowledgeDocumentDto | null>(null);
|
||||
const [editDocModalOpen, setEditDocModalOpen] = useState(false);
|
||||
const [editDocSaving, setEditDocSaving] = useState(false);
|
||||
const [editDocForm] = Form.useForm<DocForm>();
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<KnowledgeBaseDto>(
|
||||
@@ -195,28 +200,52 @@ export default function KnowledgeBasesPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
width: 140,
|
||||
render: (_, row) =>
|
||||
drawerKb?.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="删除文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
message.success('已删除');
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
if (!drawerKb) return;
|
||||
try {
|
||||
const detail = await request<KnowledgeDocumentDetailDto>(
|
||||
`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`,
|
||||
);
|
||||
setEditingDoc(row);
|
||||
editDocForm.setFieldsValue({
|
||||
title: detail.title,
|
||||
contentText: detail.contentText || '',
|
||||
});
|
||||
setEditDocModalOpen(true);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="删除文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
message.success('已删除');
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
@@ -398,6 +427,50 @@ export default function KnowledgeBasesPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑知识文档"
|
||||
open={editDocModalOpen}
|
||||
onCancel={() => {
|
||||
setEditDocModalOpen(false);
|
||||
setEditingDoc(null);
|
||||
}}
|
||||
confirmLoading={editDocSaving}
|
||||
onOk={async () => {
|
||||
if (!drawerKb || !editingDoc) return;
|
||||
const values = await editDocForm.validateFields();
|
||||
setEditDocSaving(true);
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${editingDoc.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title: values.title,
|
||||
contentText: values.contentText || null,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setEditDocModalOpen(false);
|
||||
setEditingDoc(null);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setEditDocSaving(false);
|
||||
}
|
||||
}}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={editDocForm} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contentText" label="正文(可粘贴)">
|
||||
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from '../lib/constants';
|
||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
|
||||
type ShipDefaults = {
|
||||
provider: string;
|
||||
@@ -186,6 +188,7 @@ export default function OrdersPage() {
|
||||
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [proxyOpen, setProxyOpen] = useState(false);
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||
|
||||
@@ -223,6 +226,7 @@ export default function OrdersPage() {
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -430,7 +434,17 @@ export default function OrdersPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '城市',
|
||||
width: 90,
|
||||
@@ -598,6 +612,9 @@ export default function OrdersPage() {
|
||||
options={[{ value: true, label: '仅待确认大单' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
@@ -859,7 +876,20 @@ export default function OrdersPage() {
|
||||
</Descriptions>
|
||||
|
||||
{detail.delivery && (
|
||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||
<Descriptions
|
||||
column={1}
|
||||
bordered
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<span>配送</span>
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setTrackOpen(true)}>
|
||||
查看路由
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Descriptions.Item label="快递公司">
|
||||
{detail.delivery.logisticsCompany ||
|
||||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
||||
@@ -1232,6 +1262,14 @@ export default function OrdersPage() {
|
||||
void openDetail(order.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<OrderTrackDrawer
|
||||
open={trackOpen}
|
||||
orderId={detail?.id ?? null}
|
||||
orderNo={detail?.orderNo}
|
||||
orderStatus={detail?.status}
|
||||
onClose={() => setTrackOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ function mapToForm(d: Record<string, unknown>) {
|
||||
const row = d as Row;
|
||||
return {
|
||||
...row,
|
||||
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
|
||||
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
|
||||
features: row.features?.length
|
||||
? row.features
|
||||
: [{ icon: 'star', title: '', desc: '' }],
|
||||
@@ -68,12 +68,14 @@ function buildPayload(v: TemplateFormValues) {
|
||||
function TemplateContentFields() {
|
||||
return (
|
||||
<>
|
||||
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改)</Typography.Text>
|
||||
<DetailImageUrlList
|
||||
label="详情图"
|
||||
bizType="DETAIL_TEMPLATE"
|
||||
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
||||
/>
|
||||
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改,支持批量上传)</Typography.Text>
|
||||
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
|
||||
<DetailImageUrlList
|
||||
label="详情图"
|
||||
bizType="DETAIL_TEMPLATE"
|
||||
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Form.Item name="storyTitle" label="故事标题">
|
||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||
@@ -228,7 +230,7 @@ export default function ProductDetailTemplatesPage() {
|
||||
}} width={640}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{
|
||||
status: 'ACTIVE', sortOrder: 0,
|
||||
detailImageUrls: [''],
|
||||
detailImageUrls: [],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||
Switch, Table, Tabs, Tag, Typography, message,
|
||||
@@ -9,6 +9,7 @@ import { request } from '../lib/api';
|
||||
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import MultiImageUpload from '../components/MultiImageUpload';
|
||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
@@ -67,21 +68,14 @@ type ProductFormValues = {
|
||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||
};
|
||||
|
||||
type UserPickRow = {
|
||||
id: string;
|
||||
phone?: string | null;
|
||||
nickname?: string | null;
|
||||
userNo?: string;
|
||||
};
|
||||
|
||||
function mapDetailToForm(d: Record<string, unknown>) {
|
||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||
const row = d as Row;
|
||||
return {
|
||||
...d,
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
|
||||
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
|
||||
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
|
||||
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
|
||||
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
||||
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
||||
allowOnSitePickup: !!row.allowOnSitePickup,
|
||||
@@ -112,10 +106,6 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
features: features.length ? features : undefined,
|
||||
};
|
||||
|
||||
const visibilityPhones = (v.visibilityPhones ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
barcode69: v.barcode69,
|
||||
name: v.name,
|
||||
@@ -131,7 +121,6 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
allowCrossCityDelivery:
|
||||
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
@@ -141,25 +130,13 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
|
||||
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
||||
return (
|
||||
<Form.List name={name}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
||||
添加{label}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name={name} style={{ marginBottom: 0 }}>
|
||||
<MultiImageUpload
|
||||
bizType={bizType}
|
||||
mediaType="IMAGE"
|
||||
tip={`${label}支持一次选择多张批量上传`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,11 +145,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
||||
<>
|
||||
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
||||
<Divider />
|
||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL)</Typography.Text>
|
||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL,支持批量上传,单张最大 10MB)</Typography.Text>
|
||||
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
||||
<Divider />
|
||||
<Typography.Text type="secondary">详情长图(DETAIL,可逐张修改)</Typography.Text>
|
||||
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
||||
<Typography.Text type="secondary">详情长图(DETAIL,支持批量上传,单张最大 10MB)</Typography.Text>
|
||||
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
|
||||
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Form.Item name="storyTitle" label="故事标题">
|
||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||
@@ -213,33 +192,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
||||
}
|
||||
|
||||
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
||||
const [userSearching, setUserSearching] = useState(false);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
const q = keyword.trim();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
if (!q) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
setUserSearching(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
||||
} catch {
|
||||
setUserOptions([]);
|
||||
} finally {
|
||||
setUserSearching(false);
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||
|
||||
return (
|
||||
@@ -248,46 +200,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
|
||||
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
{enabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityPhones"
|
||||
label="白名单手机号"
|
||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
||||
placeholder="输入手机号后回车"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="从用户库添加">
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
placeholder="按手机号搜索用户"
|
||||
loading={userSearching}
|
||||
options={userOptions.map((u) => ({
|
||||
value: u.phone!,
|
||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
||||
}))}
|
||||
onSearch={searchUsers}
|
||||
onSelect={(phone: string) => {
|
||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
||||
if (!cur.includes(phone)) {
|
||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
||||
}
|
||||
}}
|
||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
可见手机号见白名单管理
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -419,8 +339,8 @@ export default function ProductsPage() {
|
||||
title: '白名单',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
render: (v: boolean) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
@@ -539,7 +459,7 @@ export default function ProductsPage() {
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
||||
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
||||
carouselUrls: [''], detailImageUrls: [''],
|
||||
carouselUrls: [], detailImageUrls: [],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
<Tabs items={[
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
@@ -13,30 +14,62 @@ type Row = {
|
||||
settleAmount: number;
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
isTest?: boolean;
|
||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
||||
store?: { name: string; cityName: string };
|
||||
coupon?: { couponNo: string };
|
||||
};
|
||||
|
||||
function maskPhone(phone: string | null | undefined) {
|
||||
if (!phone || phone.length < 7) return phone ?? '—';
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/redeem-records',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.channel) qs.set('channel', filters.channel);
|
||||
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
|
||||
if (filters.storeId) qs.set('storeId', String(filters.storeId));
|
||||
if (filters.channel) qs.set('channel', String(filters.channel));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetailLoading(true);
|
||||
setDrawerOpen(true);
|
||||
try {
|
||||
setDetail(await request(`/admin/redeem-records/${id}`));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '核销号',
|
||||
dataIndex: 'redeemNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<span>
|
||||
{v}
|
||||
{row.isTest ? (
|
||||
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||
测试
|
||||
</Tag>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '方式',
|
||||
dataIndex: 'channel',
|
||||
@@ -50,7 +83,19 @@ export default function RedeemRecordsPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
||||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
|
||||
{
|
||||
title: '用户昵称',
|
||||
dataIndex: ['user', 'nickname'],
|
||||
width: 100,
|
||||
render: (v: string | null | undefined) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '用户手机',
|
||||
dataIndex: ['user', 'phone'],
|
||||
width: 120,
|
||||
render: (v: string | null | undefined) => maskPhone(v),
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||||
@@ -60,14 +105,7 @@ export default function RedeemRecordsPage() {
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
@@ -102,6 +140,9 @@ export default function RedeemRecordsPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
@@ -113,7 +154,7 @@ export default function RedeemRecordsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -125,22 +166,21 @@ export default function RedeemRecordsPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="方式">
|
||||
{
|
||||
REDEEM_CHANNEL_LABELS[
|
||||
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel
|
||||
]
|
||||
}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
<Drawer
|
||||
title="核销详情"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
{detailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : detail ? (
|
||||
<RedeemRecordDetailDescriptions detail={detail} />
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
@@ -15,6 +15,7 @@ type Row = {
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
storeCount?: number;
|
||||
staffCount?: number;
|
||||
bankAccountName?: string | null;
|
||||
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
|
||||
export default function StoreAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.status) qs.set('status', String(filters.status));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '绑定门店',
|
||||
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Drawer,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
@@ -13,19 +16,97 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type {
|
||||
StorePackageAuditDetailDto,
|
||||
StorePackageAuditSummaryDto,
|
||||
StorePackageChangeRequestDto,
|
||||
StorePackageChangeStatus,
|
||||
StorePackageItemDto,
|
||||
StorePackageViewDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '待审核',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||
const name = String(pkg.name ?? '').trim();
|
||||
return name ? `name:${name}` : `idx:${index}`;
|
||||
}
|
||||
|
||||
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||
}
|
||||
|
||||
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||||
|
||||
/** 文本逐字差异:LCS 比对,产出 equal / delete / insert 段落,用于高亮具体变了哪些字 */
|
||||
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--) {
|
||||
for (let j = n - 1; j >= 0; j--) {
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (a[i] === b[j]) {
|
||||
raw.push({ type: 'equal', text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
raw.push({ type: 'delete', text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
raw.push({ type: 'insert', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
||||
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
||||
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||
for (const s of raw) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.type === s.type) last.text += s.text;
|
||||
else merged.push({ ...s });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** 逐字段比较套餐内容,返回发生变化的字段明细(用于“变动的地方详细列出”) */
|
||||
function fieldChanges(
|
||||
live: StorePackageItemDto | StorePackageViewDto,
|
||||
proposed: StorePackageItemDto | StorePackageViewDto,
|
||||
): FieldChange[] {
|
||||
const changes: FieldChange[] = [];
|
||||
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||||
const pushText = (label: string, oldV: string, newV: string) => {
|
||||
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||||
};
|
||||
const pushValue = (label: string, oldV: string, newV: string) => {
|
||||
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
||||
};
|
||||
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
||||
pushText('套餐名称', text(live.name), text(proposed.name));
|
||||
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
||||
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
||||
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
||||
const liveImgs = normalizeStorePackageImageUrls(live);
|
||||
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
||||
if (imageSignature(live) !== imageSignature(proposed)) {
|
||||
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||
@@ -35,6 +116,7 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
|
||||
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
live?: StorePackageViewDto;
|
||||
proposed?: StorePackageItemDto;
|
||||
changes?: FieldChange[];
|
||||
}> = [];
|
||||
|
||||
for (const key of keys) {
|
||||
@@ -49,8 +131,15 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
|
||||
l.price !== p.price ||
|
||||
l.dishes !== p.dishes ||
|
||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
|
||||
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
|
||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||
imageSignature(l) !== imageSignature(p);
|
||||
rows.push({
|
||||
key,
|
||||
change: changed ? 'changed' : 'unchanged',
|
||||
live: l,
|
||||
proposed: p,
|
||||
changes: changed ? fieldChanges(l, p) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
@@ -63,12 +152,158 @@ const CHANGE_LABELS = {
|
||||
unchanged: { text: '未变', color: 'default' },
|
||||
} as const;
|
||||
|
||||
/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */
|
||||
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||
const segs = diffText(oldText, newText);
|
||||
return (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text type="secondary">原:</Typography.Text>
|
||||
{segs
|
||||
.filter((s) => s.type !== 'insert')
|
||||
.map((s, idx) =>
|
||||
s.type === 'delete' ? (
|
||||
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
||||
{s.text || '(空)'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text type="secondary">新:</Typography.Text>
|
||||
{segs
|
||||
.filter((s) => s.type !== 'delete')
|
||||
.map((s, idx) =>
|
||||
s.type === 'insert' ? (
|
||||
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
||||
{s.text || '(空)'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageDetailCard({
|
||||
title,
|
||||
pkg,
|
||||
change,
|
||||
changes,
|
||||
}: {
|
||||
title?: string;
|
||||
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||
change?: keyof typeof CHANGE_LABELS;
|
||||
changes?: FieldChange[];
|
||||
}) {
|
||||
const images = normalizeStorePackageImageUrls(pkg);
|
||||
const meta = change ? CHANGE_LABELS[change] : null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Space style={{ marginBottom: 8 }} wrap>
|
||||
{title ? (
|
||||
<Typography.Text type="secondary">{title}</Typography.Text>
|
||||
) : null}
|
||||
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||||
</Space>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>{pkg.name}</strong>
|
||||
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||
</div>
|
||||
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||
{pkg.dishes || '—'}
|
||||
</Typography.Paragraph>
|
||||
{pkg.usableTime ? (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
className="admin-package-audit-text"
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
可用时间:{pkg.usableTime}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{pkg.otherNotes ? (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
className="admin-package-audit-text"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
其他说明:{pkg.otherNotes}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{images.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{images.map((url) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={72}
|
||||
height={72}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||
)}
|
||||
{changes && changes.length ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffe7ba',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||
变更明细
|
||||
</Typography.Text>
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||
{changes.map((c) => (
|
||||
<li key={c.label} style={{ marginBottom: 6 }}>
|
||||
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
||||
{c.kind === 'text' ? (
|
||||
<TextDiff oldText={c.old} newText={c.now} />
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text delete type="secondary">
|
||||
{c.old}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>{c.now}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StorePackageAuditsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<string>('PENDING');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
@@ -84,12 +319,14 @@ export default function StorePackageAuditsPage() {
|
||||
pageSize: '20',
|
||||
});
|
||||
if (nextStatus) qs.set('status', nextStatus);
|
||||
const data = await request<Paginated<StorePackageChangeRequestDto>>(
|
||||
`/admin/store-package-audits?${qs}`,
|
||||
);
|
||||
const [data, summary] = await Promise.all([
|
||||
request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`),
|
||||
request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'),
|
||||
]);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page);
|
||||
setPendingCount(summary.pendingCount ?? 0);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
@@ -101,6 +338,14 @@ export default function StorePackageAuditsPage() {
|
||||
void reload(1, status);
|
||||
}, [status]);
|
||||
|
||||
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
||||
const [searchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const rid = searchParams.get('requestId');
|
||||
if (rid) void openDetail(rid);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
@@ -126,6 +371,7 @@ export default function StorePackageAuditsPage() {
|
||||
});
|
||||
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||
setDetailOpen(false);
|
||||
notifyPackageAuditChanged();
|
||||
void reload(page, status);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
@@ -133,54 +379,12 @@ export default function StorePackageAuditsPage() {
|
||||
}
|
||||
|
||||
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||
|
||||
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
|
||||
{
|
||||
title: '变更',
|
||||
dataIndex: 'change',
|
||||
width: 72,
|
||||
render: (v: keyof typeof CHANGE_LABELS) => {
|
||||
const meta = CHANGE_LABELS[v];
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '当前线上',
|
||||
render: (_, row) =>
|
||||
row.live ? (
|
||||
<div>
|
||||
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
|
||||
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
|
||||
{row.live.usableTime ? (
|
||||
<div><Typography.Text type="secondary">可用:{row.live.usableTime}</Typography.Text></div>
|
||||
) : null}
|
||||
{row.live.otherNotes ? (
|
||||
<div><Typography.Text type="secondary">备注:{row.live.otherNotes}</Typography.Text></div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '申请变更',
|
||||
render: (_, row) =>
|
||||
row.proposed ? (
|
||||
<div>
|
||||
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
|
||||
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
|
||||
{row.proposed.usableTime ? (
|
||||
<div><Typography.Text type="secondary">可用:{row.proposed.usableTime}</Typography.Text></div>
|
||||
) : null}
|
||||
{row.proposed.otherNotes ? (
|
||||
<div><Typography.Text type="secondary">备注:{row.proposed.otherNotes}</Typography.Text></div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
];
|
||||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||
|
||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
@@ -188,7 +392,7 @@ export default function StorePackageAuditsPage() {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: StorePackageChangeRequestDto['status']) => (
|
||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -238,7 +442,15 @@ export default function StorePackageAuditsPage() {
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
|
||||
{s === 'PENDING' ? (
|
||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
||||
</Badge>
|
||||
) : s ? (
|
||||
HQ_PACKAGE_STATUS_LABELS[s]
|
||||
) : (
|
||||
'全部'
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
@@ -257,7 +469,7 @@ export default function StorePackageAuditsPage() {
|
||||
|
||||
<Drawer
|
||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||
width={720}
|
||||
width={880}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
extra={
|
||||
@@ -282,8 +494,8 @@ export default function StorePackageAuditsPage() {
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : detail ? (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||
</Typography.Text>
|
||||
@@ -291,16 +503,54 @@ export default function StorePackageAuditsPage() {
|
||||
{detail.rejectReason ? (
|
||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||
) : null}
|
||||
<Typography.Paragraph type="secondary">
|
||||
线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条
|
||||
</Typography.Paragraph>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="key"
|
||||
columns={diffColumns}
|
||||
dataSource={diffRows}
|
||||
pagination={false}
|
||||
/>
|
||||
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||||
<Typography.Text type="secondary">
|
||||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Tag color="green">新增 {addedCount}</Tag>
|
||||
<Tag color="red">删除 {removedCount}</Tag>
|
||||
<Tag color="orange">变更 {changedCount}</Tag>
|
||||
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<div className="admin-package-audit-cols">
|
||||
<div className="admin-package-audit-col">
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
线上已审核套餐
|
||||
</Typography.Title>
|
||||
{(detail.livePackages ?? []).length ? (
|
||||
(detail.livePackages ?? []).map((pkg, index) => (
|
||||
<PackageDetailCard
|
||||
key={`live-${packageKey(pkg, index)}`}
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-package-audit-col">
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
待审核套餐
|
||||
</Typography.Title>
|
||||
{(detail.packages ?? []).length ? (
|
||||
(detail.packages ?? []).map((pkg, index) => (
|
||||
<PackageDetailCard
|
||||
key={`pending-${packageKey(pkg, index)}`}
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
changes={changesByKey.get(packageKey(pkg, index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -22,11 +23,11 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
RESOURCE_BIZ_TYPE_LABELS,
|
||||
STORE_AUDIT_STATUS_LABELS,
|
||||
STORE_STATUS_LABELS,
|
||||
fmtTime,
|
||||
@@ -40,8 +41,11 @@ import { useAdminList } from '../lib/useAdminList';
|
||||
import { resolveRegionBinding } from '../lib/china-region';
|
||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import MultiImageUpload from '../components/MultiImageUpload';
|
||||
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
|
||||
import AdminStorePackagesSection, {
|
||||
type AdminStorePackagesHandle,
|
||||
} from '../components/AdminStorePackagesSection';
|
||||
|
||||
const CREATE_STEPS = [
|
||||
{ title: '基本信息' },
|
||||
@@ -81,18 +85,6 @@ type StoreMediaItem = {
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
function isImageMedia(url: string, mediaType?: string) {
|
||||
if (mediaType === 'IMAGE') return true;
|
||||
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
||||
}
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function isPdfUrl(url: string) {
|
||||
return /\.pdf(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function collectMediaUrls(detail: Record<string, unknown>) {
|
||||
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
|
||||
const byType = (bizType: string) =>
|
||||
@@ -118,195 +110,50 @@ function collectMediaUrls(detail: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) {
|
||||
const { covers, envs, contracts } = collectMediaUrls(detail);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
|
||||
|
||||
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreAuditMediaEditor() {
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
审核材料
|
||||
</Typography.Title>
|
||||
|
||||
{(covers.length > 0 || envs.length > 0) && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
门头照 / 环境照(点击可放大浏览)
|
||||
</Typography.Text>
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={12}>
|
||||
{gallery.map((item) => (
|
||||
<div key={item.id} style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={item.url}
|
||||
width={112}
|
||||
height={84}
|
||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
|
||||
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contracts.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
签约合同
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{contracts.map((item, index) => {
|
||||
const imageLike = isImageMedia(item.url, item.mediaType);
|
||||
const pdf = isPdfUrl(item.url);
|
||||
return (
|
||||
<div
|
||||
key={item.id || `${item.url}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{imageLike ? (
|
||||
<Image.PreviewGroup>
|
||||
<Image
|
||||
src={item.url}
|
||||
width={96}
|
||||
height={72}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 96,
|
||||
height: 72,
|
||||
borderRadius: 6,
|
||||
background: '#fff',
|
||||
border: '1px dashed #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#cf1322',
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined style={{ fontSize: 28 }} />
|
||||
</div>
|
||||
)}
|
||||
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text strong>
|
||||
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
|
||||
{contracts.length > 1 ? ` ${index + 1}` : ''}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
|
||||
{item.url}
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
{imageLike ? (
|
||||
<Typography.Text type="secondary">点击缩略图放大查看</Typography.Text>
|
||||
) : null}
|
||||
{pdf ? (
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
|
||||
页内预览 PDF
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
style={{ padding: 0 }}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
新窗口打开
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="合同预览"
|
||||
open={!!pdfUrl}
|
||||
onCancel={() => setPdfUrl(null)}
|
||||
width={900}
|
||||
footer={[
|
||||
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
|
||||
新窗口打开
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照、签约合同均最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
|
||||
/>
|
||||
<Form.Item name="coverUrl" label="门头照">
|
||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="envPhotoUrls"
|
||||
label="环境照片"
|
||||
extra="建议至少 3 张;支持批量上传,最多 20 张。可逐张删除后保存。"
|
||||
>
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
title="合同 PDF 预览"
|
||||
src={pdfUrl}
|
||||
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
<MultiImageUpload
|
||||
bizType="STORE_ENV"
|
||||
mediaType="IMAGE"
|
||||
maxCount={20}
|
||||
tip="环境照支持一次选择多张批量上传"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="contractUrls"
|
||||
label="签约合同"
|
||||
style={{ marginTop: 16 }}
|
||||
extra="支持多张合同照片(如首页、盖章页),也可上传 PDF,最多 20 个。"
|
||||
>
|
||||
<MultiImageUpload
|
||||
bizType="STORE_CONTRACT"
|
||||
mediaType="FILE"
|
||||
accept="image/*,.pdf"
|
||||
maxCount={20}
|
||||
buttonText="批量上传合同"
|
||||
tip="合同支持一次选择多张照片批量上传,最多 20 个"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
|
||||
|
||||
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
||||
const [userSearching, setUserSearching] = useState(false);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
const q = keyword.trim();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
if (!q) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
setUserSearching(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
||||
} catch {
|
||||
setUserOptions([]);
|
||||
} finally {
|
||||
setUserSearching(false);
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||
|
||||
return (
|
||||
@@ -315,46 +162,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
|
||||
extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
{enabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityPhones"
|
||||
label="白名单手机号"
|
||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
||||
placeholder="输入手机号后回车"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="从用户库添加">
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
placeholder="按手机号搜索用户"
|
||||
loading={userSearching}
|
||||
options={userOptions.map((u) => ({
|
||||
value: u.phone!,
|
||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
||||
}))}
|
||||
onSearch={searchUsers}
|
||||
onSelect={(phone: string) => {
|
||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
||||
if (!cur.includes(phone)) {
|
||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
||||
}
|
||||
}}
|
||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
可见手机号见白名单管理
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -375,8 +190,11 @@ type StoreRow = {
|
||||
createdAt: string;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
|
||||
pendingPackageAuditId?: string | null;
|
||||
isTest?: boolean;
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { companyName: string };
|
||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
account?: {
|
||||
phone: string;
|
||||
name: string;
|
||||
@@ -391,16 +209,18 @@ type StoreRow = {
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
settlementRate?: number;
|
||||
sortOrder?: number;
|
||||
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string; name?: string };
|
||||
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
|
||||
function partnerOptionLabel(p: PartnerOption): string {
|
||||
const company = (p.companyName || '').trim();
|
||||
const person = (p.name || '').trim();
|
||||
const phone = (p.phone || '').trim();
|
||||
if (company && person) return `${company}-${person}`;
|
||||
return company || person || p.id;
|
||||
return company || person || phone || p.id;
|
||||
}
|
||||
type CityOption = {
|
||||
id: string;
|
||||
@@ -423,8 +243,8 @@ export default function StoresPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
if (initialCityId) init.cityId = initialCityId;
|
||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||
return init;
|
||||
@@ -433,12 +253,13 @@ export default function StoresPage() {
|
||||
'/admin/stores',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.name) qs.set('name', String(filters.name));
|
||||
if (filters.status) qs.set('status', String(filters.status));
|
||||
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -447,6 +268,7 @@ export default function StoresPage() {
|
||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
@@ -561,12 +383,18 @@ export default function StoresPage() {
|
||||
account?.phone ||
|
||||
(typeof d.phone === 'string' ? d.phone : undefined);
|
||||
const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
|
||||
const contactPhone =
|
||||
(typeof d.contactPhone === 'string' && d.contactPhone.trim()) ||
|
||||
storePhone ||
|
||||
loginPhone ||
|
||||
'';
|
||||
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
|
||||
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
|
||||
phone: storePhone || loginPhone,
|
||||
// 登录手机号(老板);与 StoreAccount 同步
|
||||
phone: loginPhone || storePhone,
|
||||
contactPhone,
|
||||
intro: d.intro,
|
||||
benefitUsageRule:
|
||||
d.benefitUsageRule != null &&
|
||||
@@ -575,6 +403,15 @@ export default function StoresPage() {
|
||||
? String(d.benefitUsageRule)
|
||||
: '',
|
||||
coverUrl: d.coverUrl,
|
||||
envPhotoUrls: (() => {
|
||||
const { envs } = collectMediaUrls(d);
|
||||
const urls = envs.map((item) => item.url).filter(Boolean);
|
||||
return urls;
|
||||
})(),
|
||||
contractUrls: (() => {
|
||||
const { contracts } = collectMediaUrls(d);
|
||||
return contracts.map((item) => item.url).filter(Boolean);
|
||||
})(),
|
||||
province: d.province,
|
||||
city: d.cityName,
|
||||
district: d.district,
|
||||
@@ -596,6 +433,8 @@ export default function StoresPage() {
|
||||
visibilityPhones: Array.isArray(d.visibilityPhones)
|
||||
? (d.visibilityPhones as string[])
|
||||
: [],
|
||||
isTest: !!d.isTest,
|
||||
sortOrder: d.sortOrder != null ? Number(d.sortOrder) : 0,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
@@ -613,7 +452,14 @@ export default function StoresPage() {
|
||||
const payload = {
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
coverUrl: v.coverUrl,
|
||||
contactPhone: String(v.contactPhone || '').trim() || v.phone,
|
||||
coverUrl: v.coverUrl ?? '',
|
||||
envPhotoUrls: Array.isArray(v.envPhotoUrls)
|
||||
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
|
||||
: [],
|
||||
contractUrls: Array.isArray(v.contractUrls)
|
||||
? v.contractUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
|
||||
: [],
|
||||
intro: v.intro,
|
||||
benefitUsageRule:
|
||||
typeof v.benefitUsageRule === 'string' &&
|
||||
@@ -636,9 +482,8 @@ export default function StoresPage() {
|
||||
bankAccountNo: v.bankAccountNo ?? null,
|
||||
bankBranch: v.bankBranch ?? null,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
isTest: !!v.isTest,
|
||||
sortOrder: v.sortOrder != null ? Number(v.sortOrder) : 0,
|
||||
...(hasCoords
|
||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||
: {}),
|
||||
@@ -647,7 +492,10 @@ export default function StoresPage() {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('门店信息已保存');
|
||||
const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true });
|
||||
message.success(
|
||||
packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存',
|
||||
);
|
||||
setDetail(updated);
|
||||
setPhoneMismatch(null);
|
||||
void reload();
|
||||
@@ -721,8 +569,9 @@ export default function StoresPage() {
|
||||
function openCreateModal() {
|
||||
void loadOptions();
|
||||
createForm.setFieldsValue({
|
||||
envPhotoUrls: ['', '', ''],
|
||||
envPhotoUrls: [],
|
||||
settlementRate: 60,
|
||||
sortOrder: 0,
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
openTime2: undefined,
|
||||
@@ -773,6 +622,7 @@ export default function StoresPage() {
|
||||
}
|
||||
|
||||
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
||||
const contractUrls = (values.contractUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
||||
await request('/admin/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -783,6 +633,7 @@ export default function StoresPage() {
|
||||
city: values.city,
|
||||
name: values.name.trim(),
|
||||
phone: values.phone.trim(),
|
||||
contactPhone: String(values.contactPhone || values.phone || '').trim(),
|
||||
district: values.district.trim(),
|
||||
address: values.address.trim(),
|
||||
...(values.latitude != null &&
|
||||
@@ -806,15 +657,13 @@ export default function StoresPage() {
|
||||
: {}),
|
||||
coverUrl: values.coverUrl?.trim() || undefined,
|
||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||
contractUrl: values.contractUrl?.trim() || undefined,
|
||||
contractUrls: contractUrls.length ? contractUrls : undefined,
|
||||
bankAccountName: values.bankAccountName.trim(),
|
||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: values.bankBranch.trim(),
|
||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||
sortOrder: values.sortOrder != null ? Number(values.sortOrder) : 0,
|
||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||
visibilityPhones: (values.visibilityPhones ?? [])
|
||||
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
});
|
||||
message.success('门店已创建');
|
||||
@@ -838,14 +687,37 @@ export default function StoresPage() {
|
||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||
},
|
||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||
{
|
||||
title: '门店名',
|
||||
dataIndex: 'name',
|
||||
width: 180,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (v: string, row) => {
|
||||
const name = v || '—';
|
||||
return (
|
||||
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
|
||||
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
|
||||
{name}
|
||||
</Typography.Text>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.category?.name || '—',
|
||||
},
|
||||
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
|
||||
{ title: '登录号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'contactPhone',
|
||||
width: 120,
|
||||
render: (v: string | null | undefined, row) => v || row.phone,
|
||||
},
|
||||
{
|
||||
title: '营业状态', dataIndex: 'status', width: 90,
|
||||
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
||||
@@ -856,10 +728,10 @@ export default function StoresPage() {
|
||||
const status = s || 'APPROVED';
|
||||
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
|
||||
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
|
||||
{status === 'REJECTED' && row.rejectReason ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
|
||||
{row.rejectReason}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
@@ -867,23 +739,58 @@ export default function StoresPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
|
||||
{
|
||||
title: '开城合伙人',
|
||||
dataIndex: 'partner',
|
||||
width: 140,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (partner: StoreRow['partner']) => {
|
||||
if (!partner) return '—';
|
||||
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
|
||||
return (
|
||||
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '可见',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
render: (v) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 140,
|
||||
title: '操作',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Space size={0} wrap>
|
||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
||||
{row.pendingPackageAuditId ? (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||
>
|
||||
审核套餐
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||
>
|
||||
对比
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -932,6 +839,9 @@ export default function StoresPage() {
|
||||
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
@@ -948,8 +858,24 @@ export default function StoresPage() {
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1720 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space wrap>
|
||||
@@ -1046,18 +972,34 @@ export default function StoresPage() {
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
|
||||
message={`主账号登录号为 ${phoneMismatch},与门店登录字段不一致。保存「登录手机号」将同步到门店端登录账号。`}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机号"
|
||||
label="登录手机号(老板)"
|
||||
rules={[{ required: true }]}
|
||||
extra="门店端短信登录使用此号码;修改后需用新号重新登录"
|
||||
extra="门店端主账号短信登录;修改后需用新号重新登录"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="contactPhone"
|
||||
label="联系电话(店长/对外)"
|
||||
rules={[
|
||||
{ required: true, message: '请填写对外联系电话' },
|
||||
{
|
||||
validator: (_, value) =>
|
||||
isStoreContactPhone(String(value || ''))
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)),
|
||||
},
|
||||
]}
|
||||
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机"
|
||||
>
|
||||
<Input placeholder="手机号或座机,如 0379-8888888" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="categoryParentId"
|
||||
label="门店分类(大类)"
|
||||
@@ -1152,6 +1094,13 @@ export default function StoresPage() {
|
||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sortOrder"
|
||||
label="排序"
|
||||
extra="数值越小越靠前;同排序时按距离(有定位)或创建时间"
|
||||
>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||
<Input type="time" style={{ width: 140 }} />
|
||||
@@ -1169,6 +1118,14 @@ export default function StoresPage() {
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<StoreVisibilityWhitelistFields form={editForm} />
|
||||
<Form.Item
|
||||
name="isTest"
|
||||
label="测试门店"
|
||||
valuePropName="checked"
|
||||
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -1213,12 +1170,14 @@ export default function StoresPage() {
|
||||
{
|
||||
key: 'media',
|
||||
label: '审核材料',
|
||||
children: <StoreAuditMediaSection detail={detail} />,
|
||||
forceRender: true,
|
||||
children: <StoreAuditMediaEditor />,
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
label: '套餐',
|
||||
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
|
||||
forceRender: true,
|
||||
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -1316,8 +1275,27 @@ export default function StoresPage() {
|
||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||
<Input placeholder="请输入门店名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
||||
<Input placeholder="11位手机号" />
|
||||
<Form.Item name="phone" label="登录手机号(老板)" rules={[{ required: true, message: '请填写登录手机号' }]}>
|
||||
<Input placeholder="门店端主账号登录" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="contactPhone"
|
||||
label="联系电话(店长/对外)"
|
||||
extra="用户端拨号展示;留空则与登录号相同。支持座机"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw || isStoreContactPhone(raw)) return Promise.resolve();
|
||||
return Promise.reject(new Error(STORE_CONTACT_PHONE_HINT));
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="手机号或座机,如 0379-8888888" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||
@@ -1403,26 +1381,31 @@ export default function StoresPage() {
|
||||
<Form.Item name="coverUrl" label="门头照">
|
||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Typography.Text strong>环境照片</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||
至少 3 张,可继续添加
|
||||
</Typography.Paragraph>
|
||||
<Form.List name="envPhotoUrls">
|
||||
{(fields, { add }) => (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
|
||||
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add('')} block>
|
||||
添加环境照片
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name="contractUrl" label="签约合同">
|
||||
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
||||
<Form.Item
|
||||
name="envPhotoUrls"
|
||||
label="环境照片"
|
||||
extra="选填;支持批量上传,最多 20 张"
|
||||
>
|
||||
<MultiImageUpload
|
||||
bizType="STORE_ENV"
|
||||
mediaType="IMAGE"
|
||||
maxCount={20}
|
||||
tip="环境照支持一次选择多张批量上传"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="contractUrls"
|
||||
label="签约合同"
|
||||
extra="选填;支持多张合同照片或 PDF,最多 20 个"
|
||||
>
|
||||
<MultiImageUpload
|
||||
bizType="STORE_CONTRACT"
|
||||
mediaType="FILE"
|
||||
accept="image/*,.pdf"
|
||||
maxCount={20}
|
||||
buttonText="批量上传合同"
|
||||
tip="合同支持一次选择多张照片批量上传,最多 20 个"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,34 +61,6 @@ function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loadin
|
||||
);
|
||||
}
|
||||
|
||||
function WecomAlertTestButton() {
|
||||
const [testing, setTesting] = useState(false);
|
||||
async function onTest() {
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await request<{ ok: boolean; message: string }>(
|
||||
'/admin/system-config/wecom-alert/test',
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
message.success(res.message || '已发送测试告警');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{ marginTop: -8, marginBottom: 16 }}>
|
||||
<Button size="small" loading={testing} onClick={() => void onTest()}>
|
||||
发送测试告警
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
需已配置 WECOM_ALERT_WEBHOOK_URL 并开启上方开关
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderField(
|
||||
field: SystemConfigFieldMeta,
|
||||
configuredSecrets: string[],
|
||||
@@ -291,8 +263,6 @@ export default function SystemSettingsPage() {
|
||||
meta.configuredSecrets,
|
||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||
) : f.key === 'WECOM_ALERT_ENABLED' ? (
|
||||
<WecomAlertTestButton />
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||
|
||||
type PhoneRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
note: string | null;
|
||||
createdByHqId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
|
||||
|
||||
type LinkedPayload = {
|
||||
phone: PhoneRow;
|
||||
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
|
||||
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
|
||||
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
|
||||
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
|
||||
};
|
||||
|
||||
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
|
||||
{ value: 'user', label: 'C 端用户' },
|
||||
{ value: 'store_account', label: '门店账号' },
|
||||
{ value: 'partner', label: '合伙人' },
|
||||
{ value: 'store', label: '门店' },
|
||||
{ value: 'order', label: '订单' },
|
||||
];
|
||||
|
||||
export default function TestWhitelistPage() {
|
||||
const [mockSms, setMockSms] = useState(false);
|
||||
const [mockWechat, setMockWechat] = useState(false);
|
||||
const [mockPay, setMockPay] = useState(false);
|
||||
const [mockLoading, setMockLoading] = useState(true);
|
||||
const [mockSaving, setMockSaving] = useState(false);
|
||||
|
||||
const [phoneForm] = Form.useForm();
|
||||
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
|
||||
const [phonesLoading, setPhonesLoading] = useState(false);
|
||||
const [phonePage, setPhonePage] = useState(1);
|
||||
const [phonePageSize, setPhonePageSize] = useState(20);
|
||||
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addForm] = Form.useForm();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
|
||||
const [accountType, setAccountType] = useState<AccountType>('user');
|
||||
const [accountPhone, setAccountPhone] = useState('');
|
||||
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
|
||||
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||
const [accountPage, setAccountPage] = useState(1);
|
||||
const [accountPageSize, setAccountPageSize] = useState(20);
|
||||
|
||||
const [linkedOpen, setLinkedOpen] = useState(false);
|
||||
const [linkedLoading, setLinkedLoading] = useState(false);
|
||||
const [linked, setLinked] = useState<LinkedPayload | null>(null);
|
||||
|
||||
async function loadMockFlags() {
|
||||
setMockLoading(true);
|
||||
try {
|
||||
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||
'/admin/test-whitelist/mock-flags',
|
||||
);
|
||||
setMockSms(!!cfg.mockSms);
|
||||
setMockWechat(!!cfg.mockWechat);
|
||||
setMockPay(!!cfg.mockPay);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
|
||||
} finally {
|
||||
setMockLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
|
||||
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
|
||||
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
|
||||
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
|
||||
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
|
||||
setMockSaving(true);
|
||||
try {
|
||||
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||
'/admin/test-whitelist/mock-flags',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
setMockSms(!!cfg.mockSms);
|
||||
setMockWechat(!!cfg.mockWechat);
|
||||
setMockPay(!!cfg.mockPay);
|
||||
message.success('已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
await loadMockFlags();
|
||||
} finally {
|
||||
setMockSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const loadPhones = useCallback(async () => {
|
||||
setPhonesLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
page: String(phonePage),
|
||||
pageSize: String(phonePageSize),
|
||||
});
|
||||
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
|
||||
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
|
||||
setPhones(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
|
||||
} finally {
|
||||
setPhonesLoading(false);
|
||||
}
|
||||
}, [phonePage, phonePageSize, phoneFilters]);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
type: accountType,
|
||||
page: String(accountPage),
|
||||
pageSize: String(accountPageSize),
|
||||
});
|
||||
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
|
||||
const res = await request<Paginated<Record<string, unknown>>>(
|
||||
`/admin/test-whitelist/accounts?${qs}`,
|
||||
);
|
||||
setAccounts(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载测试账号失败');
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, [accountType, accountPage, accountPageSize, accountPhone]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMockFlags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPhones();
|
||||
}, [loadPhones]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
async function onAddPhone() {
|
||||
const v = await addForm.validateFields();
|
||||
try {
|
||||
await request('/admin/test-whitelist/phones', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
|
||||
});
|
||||
message.success('已添加');
|
||||
setAddOpen(false);
|
||||
addForm.resetFields();
|
||||
setPhonePage(1);
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '添加失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onEditPhone() {
|
||||
if (!editRow) return;
|
||||
const v = await editForm.validateFields();
|
||||
try {
|
||||
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ note: v.note ?? null }),
|
||||
});
|
||||
message.success('已更新');
|
||||
setEditOpen(false);
|
||||
setEditRow(null);
|
||||
void loadPhones();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeletePhone(id: string) {
|
||||
try {
|
||||
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onMigrate() {
|
||||
setMigrating(true);
|
||||
try {
|
||||
const res = await request<{ importedCandidates: number; added: number }>(
|
||||
'/admin/test-whitelist/migrate-visibility',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
message.success(
|
||||
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added} 个`,
|
||||
);
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导入失败');
|
||||
} finally {
|
||||
setMigrating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLinked(row: PhoneRow) {
|
||||
setLinkedOpen(true);
|
||||
setLinkedLoading(true);
|
||||
setLinked(null);
|
||||
try {
|
||||
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
|
||||
setLinked(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载关联失败');
|
||||
setLinkedOpen(false);
|
||||
} finally {
|
||||
setLinkedLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const phoneColumns: ColumnsType<PhoneRow> = [
|
||||
{ title: '手机号', dataIndex: 'phone', width: 140 },
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: fmtTime,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openLinked(row)}>
|
||||
关联账号
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditRow(row);
|
||||
editForm.setFieldsValue({ note: row.note ?? '' });
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑备注
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认移出白名单?"
|
||||
description="将同步清除该手机号关联账号的测试标记"
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void onDeletePhone(row.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function accountColumns(): ColumnsType<Record<string, unknown>> {
|
||||
if (accountType === 'user') {
|
||||
return [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'store_account') {
|
||||
return [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'partner') {
|
||||
return [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'store') {
|
||||
return [
|
||||
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110 },
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'payAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${v}`,
|
||||
},
|
||||
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
|
||||
{
|
||||
title: '用户手机',
|
||||
width: 120,
|
||||
render: (_, row) =>
|
||||
(row.user as { phone?: string | null } | undefined)?.phone || '—',
|
||||
},
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
白名单管理
|
||||
</Typography.Title>
|
||||
|
||||
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Mock 开关(与系统设置同源,勾选 = 不做真实验证)
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Checkbox
|
||||
checked={mockSms}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
|
||||
>
|
||||
短信不做真实验证
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={mockWechat}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
|
||||
>
|
||||
微信不做真实验证
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={mockPay}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
|
||||
>
|
||||
支付不做真实验证
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
配置键:{MOCK_KEYS.join(' / ')}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'phones',
|
||||
label: '手机号名单',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Form
|
||||
form={phoneForm}
|
||||
layout="inline"
|
||||
onFinish={(v) => {
|
||||
setPhoneFilters({ phone: v.phone || undefined });
|
||||
setPhonePage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input allowClear placeholder="模糊搜索" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '从可见性白名单导入',
|
||||
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
|
||||
okText: '开始导入',
|
||||
cancelText: '取消',
|
||||
onOk: () => onMigrate(),
|
||||
});
|
||||
}}
|
||||
loading={migrating}
|
||||
>
|
||||
从可见性白名单导入
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => setAddOpen(true)}>
|
||||
添加手机号
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={phonesLoading}
|
||||
columns={phoneColumns}
|
||||
dataSource={phones?.items ?? []}
|
||||
pagination={{
|
||||
current: phonePage,
|
||||
pageSize: phonePageSize,
|
||||
total: phones?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPhonePage(p);
|
||||
setPhonePageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
label: '测试账号记录',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={accountType}
|
||||
options={ACCOUNT_TYPE_OPTIONS}
|
||||
onChange={(v: AccountType) => {
|
||||
setAccountType(v);
|
||||
setAccountPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="按手机号筛选"
|
||||
style={{ width: 160 }}
|
||||
value={accountPhone}
|
||||
onChange={(e) => setAccountPhone(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setAccountPage(1);
|
||||
void loadAccounts();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (accountPage !== 1) setAccountPage(1);
|
||||
else void loadAccounts();
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={accountsLoading}
|
||||
columns={accountColumns()}
|
||||
dataSource={accounts?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: accountPage,
|
||||
pageSize: accountPageSize,
|
||||
total: accounts?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setAccountPage(p);
|
||||
setAccountPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="添加白名单手机号"
|
||||
open={addOpen}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
onOk={() => void onAddPhone()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={addForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`编辑备注 · ${editRow?.phone ?? ''}`}
|
||||
open={editOpen}
|
||||
onCancel={() => {
|
||||
setEditOpen(false);
|
||||
setEditRow(null);
|
||||
}}
|
||||
onOk={() => void onEditPhone()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
|
||||
open={linkedOpen}
|
||||
onClose={() => setLinkedOpen(false)}
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
{linkedLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : linked ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div>
|
||||
<Typography.Title level={5}>C 端用户({linked.users.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.users}
|
||||
columns={[
|
||||
{ title: '编号', dataIndex: 'userNo' },
|
||||
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>门店账号({linked.storeAccounts.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.storeAccounts}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>合伙人({linked.partners.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.partners}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>门店({linked.stores.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.stores}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,18 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||
import {
|
||||
TICKET_STATUS_LABELS,
|
||||
TICKET_TYPE_LABELS,
|
||||
ticketStatusLabel,
|
||||
type TicketStatusDto,
|
||||
type TicketTypeDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
@@ -31,6 +38,21 @@ type Row = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Partial<Record<TicketStatusDto, string>> = {
|
||||
PENDING: 'orange',
|
||||
OPEN: 'blue',
|
||||
COLLABORATING: 'cyan',
|
||||
RESOLVED: 'green',
|
||||
REJECTED: 'red',
|
||||
COMPLETED: 'green',
|
||||
CLOSED: 'default',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(TICKET_STATUS_LABELS) as TicketStatusDto[]).map((value) => ({
|
||||
value,
|
||||
label: TICKET_STATUS_LABELS[value],
|
||||
}));
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
@@ -101,7 +123,14 @@ export default function TicketsPage() {
|
||||
width: 110,
|
||||
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => (
|
||||
<Tag color={STATUS_COLOR[s as TicketStatusDto] ?? 'default'}>{ticketStatusLabel(s)}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
@@ -165,7 +194,7 @@ export default function TicketsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Input allowClear placeholder="PENDING" />
|
||||
<Select allowClear style={{ width: 140 }} placeholder="全部" options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
@@ -212,7 +241,11 @@ export default function TicketsPage() {
|
||||
<Descriptions.Item label="类型">
|
||||
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status as TicketStatusDto] ?? 'default'}>
|
||||
{ticketStatusLabel(detail.status)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">
|
||||
{String(detail.refType)} #{String(detail.refId)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -30,6 +30,13 @@ type UserOrderRow = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
|
||||
function fmtBenefit(v: number | null | undefined) {
|
||||
const n = Number(v ?? 0);
|
||||
if (!Number.isFinite(n) || n <= 0) return '—';
|
||||
return `¥${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
type UserBehaviorLog = {
|
||||
id: string;
|
||||
eventName: string;
|
||||
@@ -112,6 +119,7 @@ export default function UsersPage() {
|
||||
if (values.status !== undefined && values.status !== '') {
|
||||
qs.set('status', String(values.status));
|
||||
}
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -237,7 +245,17 @@ export default function UsersPage() {
|
||||
];
|
||||
|
||||
const columns: ColumnsType<AdminUserRow> = [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{
|
||||
title: '用户编号',
|
||||
dataIndex: 'userNo',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{
|
||||
title: '手机',
|
||||
@@ -304,6 +322,39 @@ export default function UsersPage() {
|
||||
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{
|
||||
title: '剩余权益',
|
||||
dataIndex: 'benefitBalance',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0),
|
||||
render: (v: number | undefined) =>
|
||||
Number(v ?? 0) > 0 ? (
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
{fmtBenefit(v)}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '已用权益',
|
||||
dataIndex: 'benefitUsedAmount',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
|
||||
render: (v: number | undefined) => (
|
||||
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '累计权益',
|
||||
dataIndex: 'benefitTotalAmount',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0),
|
||||
render: (v: number | undefined) => fmtBenefit(v),
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'createdAt',
|
||||
@@ -362,6 +413,9 @@ export default function UsersPage() {
|
||||
{ value: 0, label: '停用' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
@@ -375,7 +429,7 @@ export default function UsersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1500 }}
|
||||
scroll={{ x: 1830 }}
|
||||
rowSelection={canDeleteUsers ? {
|
||||
selectedRowKeys,
|
||||
preserveSelectedRowKeys: true,
|
||||
@@ -447,6 +501,18 @@ export default function UsersPage() {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="好客权益">
|
||||
<Space size={16} wrap>
|
||||
<span>
|
||||
剩余{' '}
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
{fmtBenefit(detail.benefitBalance)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span>已用 {fmtBenefit(detail.benefitUsedAmount)}</span>
|
||||
<span>累计 {fmtBenefit(detail.benefitTotalAmount)}</span>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">
|
||||
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||
|
||||
export default function WecomBotLogsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [detail, setDetail] = useState<WecomBotLogDto | null>(null);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<WecomBotLogDto>(
|
||||
'/admin/logs/wecom-bots',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.botId) qs.set('botId', filters.botId);
|
||||
if (filters.wecomUserId) qs.set('wecomUserId', filters.wecomUserId);
|
||||
if (filters.action) qs.set('action', filters.action);
|
||||
if (filters.success) qs.set('success', filters.success);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<WecomBotLogDto> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{ title: '机器人', dataIndex: 'botName', width: 120, ellipsis: true, render: (v, r) => v || r.botKey || '—' },
|
||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120, ellipsis: true },
|
||||
{ title: '动作', dataIndex: 'action', width: 160, ellipsis: true },
|
||||
{ title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'success',
|
||||
width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>,
|
||||
},
|
||||
{ title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' },
|
||||
{ title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => setDetail(row)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>企微机器人日志</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="botId" label="机器人 ID">
|
||||
<Input allowClear placeholder="wecom_bot.id" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="wecomUserId" label="企微 userid">
|
||||
<Input allowClear placeholder="userid" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="action" label="动作">
|
||||
<Input allowClear placeholder="order.read" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="success" label="结果">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: 'true', label: '成功' },
|
||||
{ value: 'false', label: '失败' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="日志详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item>
|
||||
<Descriptions.Item label="动作">{detail.action}</Descriptions.Item>
|
||||
<Descriptions.Item label="权限">{detail.permission || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="输入摘要">{detail.inputSummary || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结果">{detail.success ? '成功' : '失败'}</Descriptions.Item>
|
||||
<Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="耗时">{detail.latencyMs != null ? `${detail.latencyMs} ms` : '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,13 +20,14 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WECOM_BOT_PERMISSIONS,
|
||||
WECOM_BOT_PERMISSION_GROUPS,
|
||||
WECOM_BOT_PERMISSION_LABELS,
|
||||
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
|
||||
WECOM_BOT_ROLE_LABELS,
|
||||
WECOM_BOT_ROLES,
|
||||
type LlmApiConfigOptionDto,
|
||||
type KnowledgeBaseOptionDto,
|
||||
type WecomAibotRuntimeDto,
|
||||
type WecomBotDto,
|
||||
type WecomBotPermission,
|
||||
type WecomBotRole,
|
||||
@@ -36,17 +37,6 @@ import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
|
||||
type ListRes = {
|
||||
items: WecomBotDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
runtime?: {
|
||||
masterEnabled: boolean;
|
||||
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
|
||||
};
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
role: WecomBotRole;
|
||||
@@ -55,6 +45,7 @@ type FormValues = {
|
||||
avatarUrl?: string;
|
||||
welcome?: string;
|
||||
permissions: WecomBotPermission[];
|
||||
reviewSuperAdminWecomUserIds?: string;
|
||||
aiEnabled: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
@@ -62,18 +53,70 @@ type FormValues = {
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
function parseUserIdLines(text?: string): string[] {
|
||||
if (!text?.trim()) return [];
|
||||
return [...new Set(text.split(/[,,\s\n]+/).map((s) => s.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function formatUserIdLines(ids?: string[]): string {
|
||||
return (ids ?? []).join('\n');
|
||||
}
|
||||
|
||||
function mapDtoToForm(row: WecomBotDto): FormValues {
|
||||
return {
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
botId: row.botId,
|
||||
secret: '',
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
welcome: row.welcome || '',
|
||||
permissions: [...row.permissions],
|
||||
reviewSuperAdminWecomUserIds: formatUserIdLines(row.reviewSuperAdminWecomUserIds),
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId,
|
||||
knowledgeBaseId: row.knowledgeBaseId,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function samePermissionSet(a: WecomBotPermission[], b: WecomBotPermission[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const setB = new Set(b);
|
||||
return a.every((p) => setB.has(p));
|
||||
}
|
||||
|
||||
const CREATE_DEFAULTS: FormValues = {
|
||||
name: '',
|
||||
role: 'CUSTOMER_SERVICE',
|
||||
botId: '',
|
||||
secret: '',
|
||||
avatarUrl: '',
|
||||
welcome: '',
|
||||
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
|
||||
reviewSuperAdminWecomUserIds: '',
|
||||
aiEnabled: false,
|
||||
llmConfigId: null,
|
||||
knowledgeBaseId: null,
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
export default function WecomBotsPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WecomBotDto | null>(null);
|
||||
const [formInitial, setFormInitial] = useState<FormValues | null>(null);
|
||||
const [formReady, setFormReady] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [detail, setDetail] = useState<WecomBotDto | null>(null);
|
||||
const [runtime, setRuntime] = useState<ListRes['runtime']>();
|
||||
const [runtime, setRuntime] = useState<WecomAibotRuntimeDto>();
|
||||
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
|
||||
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
|
||||
const roleWatch = Form.useWatch('role', form);
|
||||
const permissionsWatch = Form.useWatch('permissions', form) as WecomBotPermission[] | undefined;
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<WecomBotDto>(
|
||||
'/admin/wecom-bots',
|
||||
@@ -87,11 +130,13 @@ export default function WecomBotsPage() {
|
||||
[filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// useAdminList returns items; runtime comes from same API — fetch once for banner
|
||||
void request<ListRes>('/admin/wecom-bots?page=1&pageSize=1')
|
||||
.then((res) => setRuntime(res.runtime))
|
||||
const loadRuntime = () =>
|
||||
request<WecomAibotRuntimeDto>('/admin/wecom-bots/runtime')
|
||||
.then(setRuntime)
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
void loadRuntime();
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,46 +154,51 @@ export default function WecomBotsPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || !formReady || !formInitial) return;
|
||||
form.resetFields();
|
||||
form.setFieldsValue(formInitial);
|
||||
}, [modalOpen, formReady, formInitial, form]);
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
setFormReady(false);
|
||||
setFormInitial(null);
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
role: 'CUSTOMER_SERVICE',
|
||||
botId: '',
|
||||
secret: '',
|
||||
avatarUrl: '',
|
||||
welcome: '',
|
||||
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
|
||||
aiEnabled: false,
|
||||
llmConfigId: null,
|
||||
knowledgeBaseId: null,
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
});
|
||||
setFormInitial({ ...CREATE_DEFAULTS });
|
||||
setFormReady(true);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: WecomBotDto) {
|
||||
async function openEdit(row: WecomBotDto) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
botId: row.botId,
|
||||
secret: '',
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
welcome: row.welcome || '',
|
||||
permissions: row.permissions,
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId,
|
||||
knowledgeBaseId: row.knowledgeBaseId,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
});
|
||||
setFormInitial(null);
|
||||
setFormReady(false);
|
||||
setModalOpen(true);
|
||||
try {
|
||||
const detail = await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`);
|
||||
setEditing(detail);
|
||||
setFormInitial(mapDtoToForm(detail));
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载机器人详情失败');
|
||||
setFormInitial(mapDtoToForm(row));
|
||||
} finally {
|
||||
setFormReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields();
|
||||
const permissions = (form.getFieldValue('permissions') ?? values.permissions) as WecomBotPermission[];
|
||||
if (!permissions?.length) {
|
||||
message.error('请至少选择一项权限');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
@@ -157,7 +207,8 @@ export default function WecomBotsPage() {
|
||||
botId: values.botId.trim(),
|
||||
avatarUrl: values.avatarUrl?.trim() || null,
|
||||
welcome: values.welcome?.trim() || null,
|
||||
permissions: values.permissions,
|
||||
permissions,
|
||||
reviewSuperAdminWecomUserIds: parseUserIdLines(values.reviewSuperAdminWecomUserIds),
|
||||
aiEnabled: values.aiEnabled,
|
||||
llmConfigId: values.llmConfigId || null,
|
||||
knowledgeBaseId: values.knowledgeBaseId || null,
|
||||
@@ -165,13 +216,20 @@ export default function WecomBotsPage() {
|
||||
sortOrder: values.sortOrder,
|
||||
};
|
||||
if (editing) {
|
||||
await request(`/admin/wecom-bots/${editing.id}`, {
|
||||
const saved = await request<WecomBotDto>(`/admin/wecom-bots/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
secret: values.secret?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (!samePermissionSet(permissions, saved.permissions)) {
|
||||
message.warning('权限与保存结果不一致,已用服务端数据刷新表单');
|
||||
setFormInitial(mapDtoToForm(saved));
|
||||
setEditing(saved);
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
message.success('已更新');
|
||||
} else {
|
||||
if (!values.secret?.trim()) {
|
||||
@@ -187,7 +245,7 @@ export default function WecomBotsPage() {
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
closeModal();
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
@@ -208,10 +266,10 @@ export default function WecomBotsPage() {
|
||||
|
||||
async function reloadConnections() {
|
||||
try {
|
||||
const st = await request<{
|
||||
masterEnabled: boolean;
|
||||
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
|
||||
}>('/admin/wecom-bots/reload', { method: 'POST', body: '{}' });
|
||||
const st = await request<WecomAibotRuntimeDto>('/admin/wecom-bots/reload', {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
message.success('已重载长连接');
|
||||
setRuntime(st);
|
||||
reload();
|
||||
@@ -287,7 +345,7 @@ export default function WecomBotsPage() {
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
<Button type="link" size="small" onClick={() => void openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
@@ -386,23 +444,39 @@ export default function WecomBotsPage() {
|
||||
<Modal
|
||||
title={editing ? '编辑企微机器人' : '创建企微机器人'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={closeModal}
|
||||
onOk={() => void submit()}
|
||||
confirmLoading={saving}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{formReady && formInitial ? (
|
||||
<Form
|
||||
key={editing ? `edit-${editing.id}` : 'create'}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
preserve={false}
|
||||
initialValues={formInitial}
|
||||
>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="如:客服机器人" maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
|
||||
onChange={(role: WecomBotRole) => {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const role = form.getFieldValue('role') as WecomBotRole | undefined;
|
||||
if (!role) return;
|
||||
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
应用当前角色默认权限
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item name="avatarUrl" label="头像">
|
||||
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||||
@@ -420,21 +494,48 @@ export default function WecomBotsPage() {
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="permissions"
|
||||
label="权限"
|
||||
rules={[{ required: true, message: '请至少选择一项权限' }]}
|
||||
label="权限(按模块)"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, v: WecomBotPermission[] | undefined) =>
|
||||
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少选择一项权限')),
|
||||
},
|
||||
]}
|
||||
extra={
|
||||
roleWatch
|
||||
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
|
||||
: undefined
|
||||
<>
|
||||
{roleWatch
|
||||
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
|
||||
: null}
|
||||
{permissionsWatch?.length ? (
|
||||
<Typography.Text type="secondary"> · 已选 {permissionsWatch.length} 项</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Checkbox.Group
|
||||
options={WECOM_BOT_PERMISSIONS.map((p) => ({
|
||||
value: p,
|
||||
label: WECOM_BOT_PERMISSION_LABELS[p],
|
||||
}))}
|
||||
/>
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
{WECOM_BOT_PERMISSION_GROUPS.map((group) => (
|
||||
<div key={group.key} style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong>{group.label}</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{group.permissions.map((p) => (
|
||||
<Checkbox key={p} value={p} style={{ marginInlineStart: 0, marginRight: 12 }}>
|
||||
{WECOM_BOT_PERMISSION_LABELS[p]}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
{(roleWatch === 'TECH_SUPPORT' || permissionsWatch?.includes('support_ticket.review')) && (
|
||||
<Form.Item
|
||||
name="reviewSuperAdminWecomUserIds"
|
||||
label="审批白名单(企微 userid)"
|
||||
extra="填写成员企微 userid(如 woDacESQAAD…),不是姓名/昵称;每行或逗号分隔。发「状态」或在服务端日志 inbound user= 可核对"
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="zhangsan lisi" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="welcome" label="欢迎语">
|
||||
<Input.TextArea rows={3} placeholder="进入会话时的欢迎语,可空" />
|
||||
</Form.Item>
|
||||
@@ -475,6 +576,9 @@ export default function WecomBotsPage() {
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
) : (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Drawer title="机器人详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
|
||||
@@ -496,6 +600,11 @@ export default function WecomBotsPage() {
|
||||
<Descriptions.Item label="权限">
|
||||
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审批白名单">
|
||||
{detail.reviewSuperAdminWecomUserIds?.length
|
||||
? detail.reviewSuperAdminWecomUserIds.join('、')
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="欢迎语">{detail.welcome || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AI 问答">{detail.aiEnabled ? '开' : '关'}</Descriptions.Item>
|
||||
<Descriptions.Item label="语言模型">{detail.llmConfigName || '—'}</Descriptions.Item>
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WECOM_PUSH_CONDITION_GROUPS,
|
||||
WECOM_PUSH_CONDITION_LABELS,
|
||||
type WecomMessagePushDto,
|
||||
type WecomPushCondition,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
webhookUrl: string;
|
||||
enabled: boolean;
|
||||
mentionWecomUserId?: string;
|
||||
pushConditions: WecomPushCondition[];
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
function WecomPushConditionPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: WecomPushCondition[];
|
||||
onChange?: (next: WecomPushCondition[]) => void;
|
||||
}) {
|
||||
const selected = new Set(value ?? []);
|
||||
|
||||
function toggle(cond: WecomPushCondition, checked: boolean) {
|
||||
const next = new Set(selected);
|
||||
if (checked) next.add(cond);
|
||||
else next.delete(cond);
|
||||
onChange?.([...next]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
{WECOM_PUSH_CONDITION_GROUPS.map((group) => (
|
||||
<div key={group.key} style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong>{group.label}</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{group.conditions.map((c) => (
|
||||
<div key={c}>
|
||||
<Checkbox
|
||||
checked={selected.has(c)}
|
||||
onChange={(e) => toggle(c, e.target.checked)}
|
||||
>
|
||||
{WECOM_PUSH_CONDITION_LABELS[c]}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WecomMessagePushesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WecomMessagePushDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [detail, setDetail] = useState<WecomMessagePushDto | null>(null);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<WecomMessagePushDto>(
|
||||
'/admin/wecom-message-pushes',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const conditionsWatch = Form.useWatch('pushConditions', form) as WecomPushCondition[] | undefined;
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
enabled: true,
|
||||
pushConditions: ['alert.ops'],
|
||||
sortOrder: 0,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: WecomMessagePushDto) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
avatarUrl: row.avatarUrl ?? undefined,
|
||||
webhookUrl: row.webhookUrl,
|
||||
enabled: row.enabled,
|
||||
mentionWecomUserId: row.mentionWecomUserId ?? undefined,
|
||||
pushConditions: row.pushConditions,
|
||||
sortOrder: row.sortOrder,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: values.name,
|
||||
avatarUrl: values.avatarUrl || null,
|
||||
webhookUrl: values.webhookUrl,
|
||||
enabled: values.enabled,
|
||||
mentionWecomUserId: values.mentionWecomUserId?.trim() || null,
|
||||
pushConditions: values.pushConditions,
|
||||
sortOrder: values.sortOrder ?? 0,
|
||||
};
|
||||
if (editing) {
|
||||
await request(`/admin/wecom-message-pushes/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await request('/admin/wecom-message-pushes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await request(`/admin/wecom-message-pushes/${id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function testPush(id: string) {
|
||||
setTestingId(id);
|
||||
try {
|
||||
const res = await request<{ ok: boolean; message: string }>(
|
||||
`/admin/wecom-message-pushes/${id}/test`,
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
message.success(res.message || '已发送测试消息');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '测试失败');
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<WecomMessagePushDto> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (name: string, row) => (
|
||||
<Space>
|
||||
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Webhook',
|
||||
dataIndex: 'webhookUrlMasked',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '推送条件',
|
||||
dataIndex: 'pushConditions',
|
||||
render: (conds: WecomPushCondition[]) =>
|
||||
conds.map((c) => (
|
||||
<Tag key={c} style={{ marginBottom: 4 }}>
|
||||
{WECOM_PUSH_CONDITION_LABELS[c]}
|
||||
</Tag>
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 72,
|
||||
render: (v: boolean) => (v ? <Tag color="green">是</Tag> : <Tag>否</Tag>),
|
||||
},
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 64 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
loading={testingId === row.id}
|
||||
onClick={() => void testPush(row.id)}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
void request<WecomMessagePushDto>(`/admin/wecom-message-pushes/${row.id}`).then(setDetail);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
配置群机器人 Webhook 多实例,按推送条件分发运营告警、技术支持工单、开发任务派发等消息。运行时不再读取
|
||||
.env 中的 Webhook URL。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => setFilters(v as Record<string, string>)}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear placeholder="搜索" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: 'true', label: '启用' },
|
||||
{ value: 'false', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
filterForm.resetFields();
|
||||
setFilters({});
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建推送
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table<WecomMessagePushDto>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑消息推送' : '新建消息推送'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void save()}
|
||||
confirmLoading={saving}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="如:运营告警" />
|
||||
</Form.Item>
|
||||
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
||||
<OssUpload />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="webhookUrl"
|
||||
label="Webhook URL"
|
||||
rules={[{ required: true, message: '请填写 Webhook URL' }]}
|
||||
>
|
||||
<Input.Password placeholder="企微群机器人 Webhook" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="mentionWecomUserId"
|
||||
label="企微 userid(可选)"
|
||||
extra="markdown 推送用 <@userid> @ 群成员;任务派发等场景生效"
|
||||
>
|
||||
<Input placeholder="如 woDacESQAAD..." allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="pushConditions"
|
||||
label="推送条件"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, v: WecomPushCondition[] | undefined) =>
|
||||
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少勾选一项推送条件')),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<WecomPushConditionPicker />
|
||||
</Form.Item>
|
||||
{conditionsWatch?.length ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
已选 {conditionsWatch.length} 项:
|
||||
{conditionsWatch.map((c) => WECOM_PUSH_CONDITION_LABELS[c]).join('、')}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="消息推送详情"
|
||||
open={!!detail}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={null}
|
||||
width={560}
|
||||
>
|
||||
{detail ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="Webhook">{detail.webhookUrlMasked}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
|
||||
<Descriptions.Item label="userid">{detail.mentionWecomUserId || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="推送条件">
|
||||
{detail.pushConditions.map((c) => WECOM_PUSH_CONDITION_LABELS[c]).join('、')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,13 +51,35 @@ type BillItem = {
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
NO_PAYMENT_NEEDED: '无需打款',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
NO_PAYMENT_NEEDED: 'default',
|
||||
};
|
||||
|
||||
function isZeroPayable(amount: number | string | null | undefined) {
|
||||
return Number(amount ?? 0) === 0;
|
||||
}
|
||||
|
||||
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
|
||||
function displayWineryStatus(status: string, wineryAmount: number | string) {
|
||||
if (isZeroPayable(wineryAmount)) {
|
||||
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
|
||||
}
|
||||
return {
|
||||
key: status,
|
||||
label: STATUS_LABELS[status] || status,
|
||||
color: STATUS_COLORS[status] || 'default',
|
||||
};
|
||||
}
|
||||
|
||||
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
|
||||
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
|
||||
}
|
||||
|
||||
const DELIVERY_LABELS: Record<string, string> = {
|
||||
LOCAL: '同城',
|
||||
CROSS_CITY: '跨城',
|
||||
@@ -212,7 +234,7 @@ export default function WineryBillsPage() {
|
||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '酒厂应付',
|
||||
title: '应付',
|
||||
dataIndex: 'wineryAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
@@ -220,8 +242,11 @@ export default function WineryBillsPage() {
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
width: 100,
|
||||
render: (s, row) => {
|
||||
const d = displayWineryStatus(s, row.wineryAmount);
|
||||
return <Tag color={d.color}>{d.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -232,7 +257,7 @@ export default function WineryBillsPage() {
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.status === 'UNPAID' && (
|
||||
{canConfirmWineryPay(row) && (
|
||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
@@ -258,7 +283,8 @@ export default function WineryBillsPage() {
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
||||
无需打款(灰),可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{canEditWineryBank ? (
|
||||
@@ -273,7 +299,7 @@ export default function WineryBillsPage() {
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -296,7 +322,7 @@ export default function WineryBillsPage() {
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 130 }}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -347,7 +373,7 @@ export default function WineryBillsPage() {
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
@@ -368,8 +394,10 @@ export default function WineryBillsPage() {
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{displayWineryStatus(detail.status, detail.wineryAmount).label}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
@@ -395,7 +423,7 @@ export default function WineryBillsPage() {
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '酒厂应付',
|
||||
title: '应付',
|
||||
dataIndex: 'wineryAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { request } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
|
||||
|
||||
const descLabelStyle: CSSProperties = {
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -212,6 +213,14 @@ export default function PromoCodeDetailPage() {
|
||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="归因用户数"
|
||||
value={stats?.attributionCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
@@ -235,6 +244,8 @@ export default function PromoCodeDetailPage() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<PromoCodeMetricsPanel promoId={detail.id} />
|
||||
|
||||
<Modal
|
||||
title="编辑推广码"
|
||||
open={editOpen}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
DatePicker,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import {
|
||||
PROMO_METRIC_EVENT_LABELS,
|
||||
type PromoMetricEventItem,
|
||||
type PromoMetricEventType,
|
||||
type PromoMetricTimelineDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
|
||||
type Props = {
|
||||
promoId: string;
|
||||
};
|
||||
|
||||
const SERIES = [
|
||||
{ key: 'scan', name: '扫码进入', color: '#1677ff' },
|
||||
{ key: 'attribution', name: '归因用户', color: '#52c41a' },
|
||||
{ key: 'register', name: '扫码注册', color: '#faad14' },
|
||||
{ key: 'order', name: '订单', color: '#eb2f96' },
|
||||
] as const;
|
||||
|
||||
function buildTimelineQs(promoId: string, range: [Dayjs, Dayjs], granularity: 'day' | 'hour') {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
|
||||
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
|
||||
qs.set('granularity', granularity);
|
||||
return `/admin/promo-codes/${promoId}/metrics/timeline?${qs.toString()}`;
|
||||
}
|
||||
|
||||
function buildEventsQs(
|
||||
promoId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
range: [Dayjs, Dayjs],
|
||||
eventType?: PromoMetricEventType,
|
||||
) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('page', String(page));
|
||||
qs.set('pageSize', String(pageSize));
|
||||
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
|
||||
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
|
||||
if (eventType) qs.set('eventType', eventType);
|
||||
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
||||
}
|
||||
|
||||
function formatRefId(row: PromoMetricEventItem): string {
|
||||
if (row.orderId) return `订单 ${row.orderId}`;
|
||||
if (row.userId) return `用户 ${row.userId}`;
|
||||
if (row.sessionId) return `会话 ${row.sessionId}`;
|
||||
return '—';
|
||||
}
|
||||
|
||||
function formatLocation(row: PromoMetricEventItem): string {
|
||||
const parts = [row.ipProvince, row.ipCity].filter(Boolean);
|
||||
if (parts.length) return parts.join(' ');
|
||||
return '—';
|
||||
}
|
||||
|
||||
export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
|
||||
const [eventsPage, setEventsPage] = useState(1);
|
||||
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
|
||||
const loadTimeline = useCallback(() => {
|
||||
setTimelineLoading(true);
|
||||
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
|
||||
.then(setTimeline)
|
||||
.catch((e) => {
|
||||
message.error(e instanceof Error ? e.message : '加载趋势失败');
|
||||
setTimeline(null);
|
||||
})
|
||||
.finally(() => setTimelineLoading(false));
|
||||
}, [promoId, range, granularity]);
|
||||
|
||||
const loadEvents = useCallback(() => {
|
||||
setEventsLoading(true);
|
||||
return request<Paginated<PromoMetricEventItem>>(
|
||||
buildEventsQs(promoId, eventsPage, 20, range, eventType),
|
||||
)
|
||||
.then(setEvents)
|
||||
.catch((e) => {
|
||||
message.error(e instanceof Error ? e.message : '加载事件失败');
|
||||
setEvents(null);
|
||||
})
|
||||
.finally(() => setEventsLoading(false));
|
||||
}, [promoId, eventsPage, range, eventType]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTimeline();
|
||||
}, [loadTimeline]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
const chartOption = useMemo<EChartsOption>(() => {
|
||||
const buckets = timeline?.buckets ?? [];
|
||||
const xData = buckets.map((b) => b.key);
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: SERIES.map((s) => s.name), bottom: 0 },
|
||||
grid: { left: 48, right: 24, top: 24, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData,
|
||||
axisLabel: {
|
||||
rotate: granularity === 'hour' ? 45 : 0,
|
||||
formatter: (v: string) => (granularity === 'hour' ? v.slice(5, 16) : v.slice(5)),
|
||||
},
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: SERIES.map((s) => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: buckets.map((b) => b[s.key]),
|
||||
itemStyle: { color: s.color },
|
||||
markPoint: {
|
||||
symbol: 'pin',
|
||||
symbolSize: 42,
|
||||
data: (() => {
|
||||
const peak = timeline?.peak[s.key];
|
||||
if (!peak || peak.count <= 0) return [];
|
||||
return [{ name: '高峰', coord: [peak.key, peak.count], value: peak.count }];
|
||||
})(),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}, [timeline, granularity]);
|
||||
|
||||
const columns: ColumnsType<PromoMetricEventItem> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 168,
|
||||
render: (v: string) => fmtTime(v),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'eventType',
|
||||
width: 108,
|
||||
render: (v: PromoMetricEventType) => PROMO_METRIC_EVENT_LABELS[v],
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'ref',
|
||||
render: (_, row) => formatRefId(row),
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'clientIp',
|
||||
width: 128,
|
||||
render: (v: string | null) => v ?? '—',
|
||||
},
|
||||
{
|
||||
title: '地点',
|
||||
key: 'location',
|
||||
width: 120,
|
||||
render: (_, row) => formatLocation(row),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="数据趋势"
|
||||
style={{ marginTop: 16 }}
|
||||
extra={
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
仅统计功能上线后的新事件
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<DatePicker.RangePicker
|
||||
value={range}
|
||||
onChange={(v) => {
|
||||
if (v?.[0] && v[1]) {
|
||||
setRange([v[0], v[1]]);
|
||||
setEventsPage(1);
|
||||
}
|
||||
}}
|
||||
allowClear={false}
|
||||
/>
|
||||
<Radio.Group
|
||||
value={granularity}
|
||||
onChange={(e) => setGranularity(e.target.value as 'day' | 'hour')}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ label: '按日', value: 'day' },
|
||||
{ label: '按时', value: 'hour' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: 360 }}
|
||||
showLoading={timelineLoading}
|
||||
notMerge
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||
事件日志
|
||||
</Typography.Title>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部类型"
|
||||
style={{ width: 160 }}
|
||||
value={eventType}
|
||||
onChange={(v) => {
|
||||
setEventType(v);
|
||||
setEventsPage(1);
|
||||
}}
|
||||
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={events?.items ?? []}
|
||||
loading={eventsLoading}
|
||||
pagination={{
|
||||
current: eventsPage,
|
||||
pageSize: events?.pageSize ?? 20,
|
||||
total: events?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (p) => setEventsPage(p),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,10 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@dukang/domain": ["../../packages/domain/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5175,
|
||||
proxy: { '/api': 'http://localhost:3000' },
|
||||
proxy: { '/api': apiTarget },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/client-logging": "workspace:*",
|
||||
"@dukang/domain": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/weixin-sdk": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
import { enqueueUpload } from '../lib/upload-lock';
|
||||
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
|
||||
type Props = {
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
bizType: string;
|
||||
mediaType?: OssMediaType;
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
/** 系统文件选择器的 accept,默认仅图片 */
|
||||
accept?: string;
|
||||
/** 计量单位文案,如「张」「个」 */
|
||||
unit?: string;
|
||||
};
|
||||
|
||||
function isCancelError(msg: string): boolean {
|
||||
return /cancel|取消/i.test(msg);
|
||||
}
|
||||
|
||||
function isPdf(url: string): boolean {
|
||||
return /\.pdf(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
function normalizeUrls(value?: string[]) {
|
||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** 合伙人端多图批量上传(微信相册可多选) */
|
||||
export default function MultiOssUploadField({
|
||||
value,
|
||||
onChange,
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
maxCount = 20,
|
||||
disabled,
|
||||
label,
|
||||
accept = 'image/*',
|
||||
unit = '张',
|
||||
}: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pickingRef = useRef(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const urls = normalizeUrls(value);
|
||||
const urlsRef = useRef(urls);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const remaining = Math.max(0, maxCount - urls.length);
|
||||
const inWechat = isWechatEnv();
|
||||
|
||||
useEffect(() => {
|
||||
urlsRef.current = urls;
|
||||
}, [urls]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inWechat) return;
|
||||
void weixinSdk.init().catch(() => {});
|
||||
}, [inWechat]);
|
||||
|
||||
function showUploadError(text: string) {
|
||||
setError(text);
|
||||
toastError(text);
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
const current = urlsRef.current;
|
||||
const room = Math.max(0, maxCount - current.length);
|
||||
const picked = files.slice(0, room);
|
||||
if (!picked.length) {
|
||||
showUploadError(`最多 ${maxCount} ${unit}`);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setError('');
|
||||
const appended: string[] = [];
|
||||
try {
|
||||
for (const file of picked) {
|
||||
if (!file.size) continue;
|
||||
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
|
||||
appended.push(result.url);
|
||||
}
|
||||
if (appended.length) {
|
||||
const next = [...urlsRef.current, ...appended];
|
||||
urlsRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
toastSuccess(`已上传 ${appended.length} ${unit}`);
|
||||
}
|
||||
} catch (e) {
|
||||
showUploadError(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function pickWechat() {
|
||||
if (pickingRef.current || uploading || disabled || remaining <= 0) return;
|
||||
pickingRef.current = true;
|
||||
setError('');
|
||||
try {
|
||||
await weixinSdk.init();
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: Math.min(remaining, 9),
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (!files?.length) return;
|
||||
await uploadFiles(files);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (isCancelError(msg)) return;
|
||||
const formatted = formatChooseImageFailMessage(msg) || msg;
|
||||
showUploadError(formatted);
|
||||
inputRef.current?.click();
|
||||
} finally {
|
||||
pickingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
if (disabled) return;
|
||||
onChange?.(urls.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple
|
||||
className="partner-oss-upload-input"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onChange={(e) => {
|
||||
const list = Array.from(e.target.files ?? []);
|
||||
if (list.length) void uploadFiles(list);
|
||||
}}
|
||||
/>
|
||||
|
||||
{urls.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{urls.map((url, index) => (
|
||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
||||
{isPdf(url) ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 88,
|
||||
height: 88,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: '#f7f7f7',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
|
||||
description
|
||||
</span>
|
||||
<span className="text-muted">PDF</span>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-packages-remove"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
margin: 0,
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onClick={() => removeAt(index)}
|
||||
>
|
||||
删
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onClick={() => {
|
||||
if (inWechat) void pickWechat();
|
||||
else inputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading
|
||||
? '上传中…'
|
||||
: remaining <= 0
|
||||
? `已达上限 ${maxCount}${unit}`
|
||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||
</span>
|
||||
</button>
|
||||
{error ? (
|
||||
<p className="partner-form-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,6 @@ type OssUploadFieldProps = {
|
||||
onWechatReadyChange?: (ready: boolean) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
function isCancelError(msg: string): boolean {
|
||||
return /cancel|取消/i.test(msg);
|
||||
}
|
||||
@@ -79,10 +77,6 @@ export default function OssUploadField({
|
||||
showUploadError('图片读取失败,请重新选择');
|
||||
return;
|
||||
}
|
||||
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
|
||||
showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
|
||||
return;
|
||||
}
|
||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import type { PackageFormItem } from '../lib/storePackages';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { emptyPackage } from '../lib/storePackages';
|
||||
import MultiOssUploadField from './MultiOssUploadField';
|
||||
|
||||
type Props = {
|
||||
items: PackageFormItem[];
|
||||
@@ -118,28 +119,46 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
|
||||
|
||||
<div className="partner-field">
|
||||
<label>使用时间</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
<input
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量)</label>
|
||||
<MultiOssUploadField
|
||||
bizType="STORE_PACKAGE"
|
||||
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||
disabled={disabled}
|
||||
value={
|
||||
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
|
||||
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
|
||||
: item.imageUrl
|
||||
? [String(item.imageUrl)]
|
||||
: []
|
||||
}
|
||||
onChange={(imageUrls) =>
|
||||
updateAt(index, {
|
||||
imageUrls,
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>其他说明</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<input
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../lib/api';
|
||||
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { toastError } from '../lib/toast';
|
||||
|
||||
export type PartnerAccount = PartnerMe & {
|
||||
staffRole?: PartnerStaffRole;
|
||||
@@ -115,7 +116,10 @@ export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
stripOAuthParamsFromLocation();
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// 微信 OAuth 回跳后后端可能因账号暂停(DISABLED)等拒绝登录,
|
||||
// 必须把错误显式提示出来,否则用户无任何反馈(与短信路径一致)。
|
||||
toastError(e instanceof Error ? e.message : '微信登录失败');
|
||||
stripOAuthParamsFromLocation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||
import { reportApiError } from '@dukang/client-logging';
|
||||
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { showPartnerToast } from './toast';
|
||||
|
||||
@@ -159,6 +160,12 @@ async function rawRequest<T>(
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code === 401 ? 401 : json.code;
|
||||
if (json.code === 400) {
|
||||
reportApiError(
|
||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { getDefaultPartnerRegionForm } from './china-region';
|
||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||
|
||||
export type StoreDraftForm = {
|
||||
regionCodes: string[];
|
||||
cityId: string;
|
||||
@@ -5,7 +8,10 @@ export type StoreDraftForm = {
|
||||
city: string;
|
||||
district: string;
|
||||
name: string;
|
||||
/** 门店登录手机号(老板主账号) */
|
||||
phone: string;
|
||||
/** 对外联系电话(店长);可与登录号不同 */
|
||||
contactPhone: string;
|
||||
address: string;
|
||||
/** 门店坐标(定位或地理编码) */
|
||||
latitude: string;
|
||||
@@ -25,7 +31,8 @@ export type StoreDraftForm = {
|
||||
benefitUsageRule: string;
|
||||
coverUrl: string;
|
||||
envPhotoUrls: string[];
|
||||
contractUrl: string;
|
||||
/** 签约合同,支持多张照片 / PDF */
|
||||
contractUrls: string[];
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
@@ -44,13 +51,12 @@ export function storeDraftKey(accountId?: string): string {
|
||||
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
|
||||
}
|
||||
|
||||
import { getDefaultPartnerRegionForm } from './china-region';
|
||||
|
||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
...getDefaultPartnerRegionForm(),
|
||||
cityId: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
@@ -66,7 +72,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
benefitUsageRule: '',
|
||||
coverUrl: '',
|
||||
envPhotoUrls: ['', '', ''],
|
||||
contractUrl: '',
|
||||
contractUrls: [],
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
@@ -84,6 +90,9 @@ function timeToMinutes(hhmm: string): number {
|
||||
|
||||
export const MIN_ENV_PHOTO_COUNT = 3;
|
||||
|
||||
/** 签约合同最多可上传的照片 / PDF 数量 */
|
||||
export const MAX_CONTRACT_COUNT = 20;
|
||||
|
||||
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||
while (arr.length < minLen) arr.push('');
|
||||
@@ -102,6 +111,9 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
||||
...raw,
|
||||
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||
cityId: String(raw.cityId ?? base.cityId),
|
||||
phone: String(raw.phone ?? base.phone),
|
||||
contactPhone:
|
||||
String(raw.contactPhone ?? '').trim() || String(raw.phone ?? '').trim() || base.contactPhone,
|
||||
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
|
||||
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
|
||||
openTime: String(raw.openTime ?? base.openTime),
|
||||
@@ -111,6 +123,22 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
||||
// 兼容旧草稿:单个 contractUrl 迁移为数组
|
||||
contractUrls: (() => {
|
||||
const list = Array.isArray(raw.contractUrls)
|
||||
? raw.contractUrls
|
||||
: [(raw as { contractUrl?: unknown }).contractUrl];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of list) {
|
||||
const url = String(item ?? '').trim();
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
if (out.length >= MAX_CONTRACT_COUNT) break;
|
||||
}
|
||||
return out;
|
||||
})(),
|
||||
packages: Array.isArray(raw.packages)
|
||||
? raw.packages.map((p, i) => ({
|
||||
name: String((p as { name?: string }).name ?? ''),
|
||||
@@ -217,12 +245,13 @@ export function validateStoreStep1(
|
||||
}
|
||||
|
||||
export function validateStoreStep2(
|
||||
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>,
|
||||
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrls'>,
|
||||
): string | null {
|
||||
if (!form.coverUrl.trim()) return '请上传门头照';
|
||||
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
||||
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
||||
if (!form.contractUrl.trim()) return '请上传签约合同';
|
||||
const contractCount = (form.contractUrls ?? []).filter((u) => u.trim()).length;
|
||||
if (contractCount < 1) return '请上传签约合同';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -236,14 +265,16 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
|
||||
export function validateStoreStep3(
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
|
||||
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'contactPhone'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
||||
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
||||
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
||||
if (!form.bankBranch.trim()) return '请填写开户支行';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.phone.trim()) return '请填写门店登录手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店登录手机号须为11位手机号';
|
||||
if (!form.contactPhone.trim()) return '请填写联系电话';
|
||||
if (!isStoreContactPhone(form.contactPhone.trim())) return STORE_CONTACT_PHONE_HINT;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import {
|
||||
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
||||
STORE_PACKAGE_MAX_COUNT,
|
||||
normalizeStorePackageImageUrls,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PackageFormItem = StorePackageItemDto;
|
||||
|
||||
@@ -10,21 +14,36 @@ export function emptyPackage(index = 0): PackageFormItem {
|
||||
dishes: '',
|
||||
usableTime: '',
|
||||
otherNotes: '',
|
||||
imageUrl: '',
|
||||
imageUrls: [],
|
||||
sortOrder: index,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||
return raw
|
||||
.map((item, index) => ({
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || '',
|
||||
otherNotes: item.otherNotes?.trim() || '',
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes);
|
||||
.map((item, index) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls(item);
|
||||
return {
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || '',
|
||||
otherNotes: item.otherNotes?.trim() || '',
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
imageUrls,
|
||||
sortOrder: index,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item) =>
|
||||
item.name ||
|
||||
item.price ||
|
||||
item.dishes ||
|
||||
item.usableTime ||
|
||||
item.otherNotes ||
|
||||
item.imageUrls.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
|
||||
@@ -38,6 +57,9 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
||||
return `第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | stri
|
||||
|
||||
export function storeAuditLabel(auditStatus?: string | null): string {
|
||||
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
||||
if (s === 'PENDING') return '待总部审核';
|
||||
if (s === 'PENDING') return '待审核';
|
||||
if (s === 'REJECTED') return '审核驳回';
|
||||
if (s === 'APPROVED') return '审核通过';
|
||||
return auditStatus || '—';
|
||||
@@ -21,10 +21,26 @@ export function storeStatusLabel(status: string): string {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return '营业中';
|
||||
if (s === 'PAUSED') return '临时闭店';
|
||||
if (s === 'CLOSED') return '永久关闭';
|
||||
if (s === 'CLOSED') return '永久闭店';
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 列表右上角统一状态:审核未通过优先于营业状态 */
|
||||
export function storeListBadge(store: {
|
||||
status?: unknown;
|
||||
auditStatus?: unknown;
|
||||
}): { label: string; pillClass: string } {
|
||||
const audit = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||||
if (audit === 'PENDING') {
|
||||
return { label: '待审核', pillClass: storeAuditPillClass('PENDING') };
|
||||
}
|
||||
if (audit === 'REJECTED') {
|
||||
return { label: '审核驳回', pillClass: storeAuditPillClass('REJECTED') };
|
||||
}
|
||||
const status = String(store.status || '').toUpperCase();
|
||||
return { label: storeStatusLabel(status), pillClass: storeStatusPillClass(status) };
|
||||
}
|
||||
|
||||
export function storeStatusPillClass(status: string): string {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return 'partner-status-pill--open';
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { apiBase, request } from './api';
|
||||
import {
|
||||
compressImageFileIfNeeded,
|
||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||
formatOssMaxSizeMb,
|
||||
} from '@dukang/shared-ui/compressImage';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -11,13 +16,23 @@ export type UploadFileResult = {
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
||||
if (mediaType !== 'IMAGE') return file;
|
||||
const compressed = await compressImageFileIfNeeded(file);
|
||||
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
||||
}
|
||||
return compressed;
|
||||
}
|
||||
|
||||
async function uploadFileToOssInner(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
const prepared = await prepareUploadFile(file, mediaType);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', prepared);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
|
||||
@@ -48,6 +48,19 @@ export async function canPartnerUseWechatLogin(options: {
|
||||
}
|
||||
}
|
||||
|
||||
/** 短信登录/发码前:校验手机号对应合伙人存在且未暂停 */
|
||||
export async function checkPartnerPhoneForLogin(phone: string): Promise<PartnerPhoneCheckResponse> {
|
||||
const normalized = phone.trim();
|
||||
if (!/^1\d{10}$/.test(normalized)) {
|
||||
throw new Error('请输入正确的手机号');
|
||||
}
|
||||
return request<PartnerPhoneCheckResponse>('PARTNER_H5', '/partner/auth/phone/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: normalized }),
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
import type { PartnerPhoneCheckResponse } from '@dukang/shared-types';
|
||||
import {
|
||||
bindPartnerWechatAfterSmsLogin,
|
||||
checkPartnerPhoneForLogin,
|
||||
fetchClientConfig,
|
||||
loginPartnerWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
@@ -69,14 +71,25 @@ function formatPartnerError(e: unknown): string {
|
||||
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
|
||||
return '未找到合伙人账号';
|
||||
}
|
||||
if (text.includes('合伙人账号已停用')) {
|
||||
return '合伙人账号已停用';
|
||||
if (
|
||||
text.includes('合伙人账号已停用') ||
|
||||
text.includes('账号已停用') ||
|
||||
text.includes('暂停使用')
|
||||
) {
|
||||
return '该账号已暂停使用,请联系客服人员';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (
|
||||
text.includes('合伙人账号已停用') ||
|
||||
text.includes('账号已停用') ||
|
||||
text.includes('暂停使用')
|
||||
) {
|
||||
return '该账号已暂停使用,请联系客服人员';
|
||||
}
|
||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
||||
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||
}
|
||||
@@ -138,11 +151,21 @@ export default function LoginPage() {
|
||||
|
||||
async function sendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
const trimmedPhone = phone.trim();
|
||||
if (!trimmedPhone) {
|
||||
setMsg('请输入手机号');
|
||||
return;
|
||||
}
|
||||
if (!/^1\d{10}$/.test(trimmedPhone)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
try {
|
||||
await checkPartnerPhoneForLogin(trimmedPhone);
|
||||
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||
body: JSON.stringify({ phone: trimmedPhone, scene: 'PARTNER_LOGIN' }),
|
||||
silent: true,
|
||||
});
|
||||
setMsg('验证码已发送');
|
||||
@@ -168,12 +191,27 @@ export default function LoginPage() {
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
const trimmedPhone = phone.trim();
|
||||
const trimmedCode = code.trim();
|
||||
if (!trimmedPhone) {
|
||||
setMsg('请输入手机号');
|
||||
return;
|
||||
}
|
||||
if (!/^1\d{10}$/.test(trimmedPhone)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (!trimmedCode) {
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await checkPartnerPhoneForLogin(trimmedPhone);
|
||||
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
|
||||
silent: true,
|
||||
});
|
||||
saveRememberedSession(data);
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
import MultiOssUploadField from '../components/MultiOssUploadField';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
import { fetchClientConfig } from '../lib/wechat-auth';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
|
||||
import { resolveRegionBinding } from '../lib/china-region';
|
||||
@@ -40,15 +42,18 @@ import {
|
||||
|
||||
validateStoreStep3,
|
||||
|
||||
patchEnvPhotoAt,
|
||||
addEnvPhotoSlot,
|
||||
MIN_ENV_PHOTO_COUNT,
|
||||
|
||||
MAX_CONTRACT_COUNT,
|
||||
} from '../lib/storeDraft';
|
||||
import StorePackagesForm from '../components/StorePackagesForm';
|
||||
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
|
||||
|
||||
const DEFAULT_PARTNER_ONBOARD_CS_HINT =
|
||||
'使用问题、提现问题等随时可联系【杜康好客】客服';
|
||||
|
||||
type StoreCategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -57,6 +62,7 @@ type StoreCategoryNode = {
|
||||
|
||||
type FieldErrors = {
|
||||
phone?: string;
|
||||
contactPhone?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -98,6 +104,10 @@ export default function StoreCreatePage() {
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [csAdded, setCsAdded] = useState(false);
|
||||
const [csQrUrl, setCsQrUrl] = useState<string | null>(null);
|
||||
const [csHint, setCsHint] = useState(DEFAULT_PARTNER_ONBOARD_CS_HINT);
|
||||
const [csConfigLoading, setCsConfigLoading] = useState(false);
|
||||
|
||||
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||
|
||||
@@ -149,6 +159,29 @@ export default function StoreCreatePage() {
|
||||
});
|
||||
}, [step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 4) return;
|
||||
let cancelled = false;
|
||||
setCsConfigLoading(true);
|
||||
void fetchClientConfig()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return;
|
||||
setCsQrUrl((cfg.partnerOnboardCsQrUrl ?? '').trim() || null);
|
||||
setCsHint((cfg.partnerOnboardCsHint ?? '').trim() || DEFAULT_PARTNER_ONBOARD_CS_HINT);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setCsQrUrl(null);
|
||||
setCsHint(DEFAULT_PARTNER_ONBOARD_CS_HINT);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setCsConfigLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [step]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
void fetchPartnerCities()
|
||||
@@ -238,9 +271,17 @@ export default function StoreCreatePage() {
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
if ('phone' in patch) {
|
||||
if ('phone' in patch || 'contactPhone' in patch) {
|
||||
|
||||
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||||
setFieldErrors((prev) => ({
|
||||
|
||||
...prev,
|
||||
|
||||
...('phone' in patch ? { phone: undefined } : {}),
|
||||
|
||||
...('contactPhone' in patch ? { contactPhone: undefined } : {}),
|
||||
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
@@ -250,22 +291,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
|
||||
|
||||
function patchEnvPhotoUrl(index: number, url: string) {
|
||||
|
||||
setForm((prev) => ({
|
||||
|
||||
...prev,
|
||||
|
||||
envPhotoUrls: patchEnvPhotoAt(prev.envPhotoUrls, index, url),
|
||||
|
||||
}));
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function bindRegionSelection(codes: string[]) {
|
||||
|
||||
const binding = resolveRegionBinding(codes, cities);
|
||||
@@ -332,7 +357,9 @@ export default function StoreCreatePage() {
|
||||
const msg = validateStoreStep3(form);
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
setFieldErrors({ phone: msg });
|
||||
setFieldErrors(
|
||||
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
|
||||
);
|
||||
reportStepError(msg);
|
||||
return;
|
||||
}
|
||||
@@ -348,12 +375,23 @@ export default function StoreCreatePage() {
|
||||
|
||||
|
||||
async function submit(skipPackages = false) {
|
||||
const qr = (csQrUrl ?? '').trim();
|
||||
if (!qr) {
|
||||
reportFormError('客服二维码暂未配置,请联系总部');
|
||||
return;
|
||||
}
|
||||
if (!csAdded) {
|
||||
reportFormError('请先勾选「我已添加【杜康好客】客服」');
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = validateStoreStep3(form);
|
||||
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
setFieldErrors({ phone: msg });
|
||||
setFieldErrors(
|
||||
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
|
||||
);
|
||||
return;
|
||||
}
|
||||
reportFormError(msg);
|
||||
@@ -440,7 +478,11 @@ export default function StoreCreatePage() {
|
||||
|
||||
const envPhotoUrls = Array.from(
|
||||
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
||||
).slice(0, 3);
|
||||
).slice(0, 20);
|
||||
|
||||
const contractUrls = Array.from(
|
||||
new Set((form.contractUrls ?? []).map((u) => u.trim()).filter(Boolean)),
|
||||
).slice(0, MAX_CONTRACT_COUNT);
|
||||
|
||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||
|
||||
@@ -458,6 +500,8 @@ export default function StoreCreatePage() {
|
||||
|
||||
phone: form.phone.trim(),
|
||||
|
||||
contactPhone: form.contactPhone.trim() || form.phone.trim(),
|
||||
|
||||
district: form.district.trim(),
|
||||
|
||||
address: form.address.trim(),
|
||||
@@ -488,7 +532,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||
|
||||
contractUrl: form.contractUrl.trim() || undefined,
|
||||
contractUrls: contractUrls.length ? contractUrls : undefined,
|
||||
|
||||
bankAccountName: form.bankAccountName.trim(),
|
||||
|
||||
@@ -539,6 +583,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
|
||||
|
||||
const canSubmitOnboard = !!csQrUrl?.trim() && csAdded && !submitting && !csConfigLoading;
|
||||
const nextDisabled = submitting;
|
||||
|
||||
|
||||
@@ -957,46 +1002,15 @@ export default function StoreCreatePage() {
|
||||
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 {MIN_ENV_PHOTO_COUNT} 张,展示店内整洁环境</p>
|
||||
|
||||
<div className="partner-upload-grid">
|
||||
|
||||
{form.envPhotoUrls.map((url, index) => (
|
||||
|
||||
<OssUploadField
|
||||
|
||||
key={index}
|
||||
|
||||
compact
|
||||
|
||||
bizType="STORE_ENV"
|
||||
|
||||
mediaType="IMAGE"
|
||||
|
||||
value={url}
|
||||
|
||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||
|
||||
/>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ width: '100%', marginTop: 12 }}
|
||||
onClick={() =>
|
||||
setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
|
||||
}
|
||||
>
|
||||
添加环境照片
|
||||
</button>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 {MIN_ENV_PHOTO_COUNT} 张,支持批量上传,最多 20 张</p>
|
||||
<MultiOssUploadField
|
||||
bizType="STORE_ENV"
|
||||
maxCount={20}
|
||||
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
||||
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
||||
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1005,9 +1019,11 @@ export default function StoreCreatePage() {
|
||||
|
||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>
|
||||
拍照上传签约协议首页与盖章页,支持多张,最多 {MAX_CONTRACT_COUNT} 个
|
||||
</p>
|
||||
|
||||
<OssUploadField
|
||||
<MultiOssUploadField
|
||||
|
||||
bizType="STORE_CONTRACT"
|
||||
|
||||
@@ -1015,11 +1031,15 @@ export default function StoreCreatePage() {
|
||||
|
||||
accept="image/*,.pdf"
|
||||
|
||||
value={form.contractUrl}
|
||||
unit="个"
|
||||
|
||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||
maxCount={MAX_CONTRACT_COUNT}
|
||||
|
||||
label="上传合同副本"
|
||||
value={form.contractUrls ?? []}
|
||||
|
||||
onChange={(urls) => patchForm({ contractUrls: urls })}
|
||||
|
||||
label={`批量上传合同(${(form.contractUrls ?? []).length}/${MAX_CONTRACT_COUNT})`}
|
||||
|
||||
/>
|
||||
|
||||
@@ -1137,7 +1157,43 @@ export default function StoreCreatePage() {
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
该手机号将作为门店端登录账号。
|
||||
老板手机号,作为门店端主账号登录凭证。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>联系电话 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-field-input">
|
||||
|
||||
<span className="material-symbols-outlined">phone_in_talk</span>
|
||||
|
||||
<input
|
||||
|
||||
type="tel"
|
||||
|
||||
placeholder="手机号或座机,如 0379-8888888"
|
||||
|
||||
value={form.contactPhone}
|
||||
|
||||
onChange={(e) => patchForm({ contactPhone: e.target.value })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{fieldErrors.contactPhone && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.contactPhone}</p>
|
||||
|
||||
)}
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机(如 0379-8888888)。
|
||||
|
||||
</p>
|
||||
|
||||
@@ -1150,6 +1206,37 @@ export default function StoreCreatePage() {
|
||||
|
||||
{step === 4 && (
|
||||
<>
|
||||
<section className="partner-form-card partner-onboard-cs-card">
|
||||
<div className="partner-section-title">
|
||||
<div className="partner-section-bar" />
|
||||
<h2 className="headline-md">添加客服</h2>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 16, lineHeight: 1.5 }}>
|
||||
{csHint}
|
||||
</p>
|
||||
{csConfigLoading ? (
|
||||
<p className="body-md text-muted">加载客服二维码…</p>
|
||||
) : csQrUrl ? (
|
||||
<img
|
||||
className="partner-onboard-cs-qr"
|
||||
src={csQrUrl}
|
||||
alt="杜康好客企微客服二维码"
|
||||
/>
|
||||
) : (
|
||||
<p className="partner-form-error" role="alert">
|
||||
客服二维码暂未配置,请联系总部
|
||||
</p>
|
||||
)}
|
||||
<label className="partner-onboard-cs-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={csAdded}
|
||||
disabled={!csQrUrl || submitting}
|
||||
onChange={(e) => setCsAdded(e.target.checked)}
|
||||
/>
|
||||
<span>我已添加【杜康好客】客服</span>
|
||||
</label>
|
||||
</section>
|
||||
<section className="partner-form-card">
|
||||
<div className="partner-section-title">
|
||||
<div className="partner-section-bar" />
|
||||
@@ -1195,13 +1282,21 @@ export default function StoreCreatePage() {
|
||||
) : (
|
||||
|
||||
<>
|
||||
<button type="button" className="partner-btn-outline" onClick={() => void submit(true)} disabled={submitting}>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
onClick={() => void submit(true)}
|
||||
disabled={!canSubmitOnboard}
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void submit(false)} disabled={submitting}>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
onClick={() => void submit(false)}
|
||||
disabled={!canSubmitOnboard}
|
||||
>
|
||||
{submitting ? '提交中…' : '提交'}
|
||||
|
||||
</button>
|
||||
</>
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
|
||||
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray } from '../lib/storeDraft';
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
import MultiOssUploadField from '../components/MultiOssUploadField';
|
||||
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
@@ -42,7 +43,8 @@ export default function StoreDetailPage() {
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
loginPhone: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
intro: '',
|
||||
benefitUsageRule: '',
|
||||
@@ -63,7 +65,8 @@ export default function StoreDetailPage() {
|
||||
setStore(data);
|
||||
setForm({
|
||||
name: String(data.name || ''),
|
||||
phone: String(data.phone || ''),
|
||||
loginPhone: String(data.phone || ''),
|
||||
contactPhone: String(data.contactPhone || data.phone || ''),
|
||||
address: String(data.address || ''),
|
||||
intro: String(data.intro || ''),
|
||||
benefitUsageRule: (() => {
|
||||
@@ -163,7 +166,7 @@ export default function StoreDetailPage() {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
contactPhone: form.contactPhone.trim(),
|
||||
address: form.address.trim(),
|
||||
intro: form.intro.trim(),
|
||||
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
||||
@@ -282,7 +285,7 @@ export default function StoreDetailPage() {
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>门店套餐</h3>
|
||||
<button type="button" className="partner-btn-outline" onClick={() => navigate(`/stores/${id}/packages`)}>
|
||||
<button type="button" className="partner-btn-outline partner-btn-outline--compact" onClick={() => navigate(`/stores/${id}/packages`)}>
|
||||
编辑套餐
|
||||
</button>
|
||||
</div>
|
||||
@@ -345,12 +348,31 @@ export default function StoreDetailPage() {
|
||||
<label>门店名称</label>
|
||||
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店登录手机号</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">lock</span>
|
||||
<input disabled type="tel" value={form.loginPhone} readOnly />
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
老板主账号,用于门店端登录;如需变更请联系总部。
|
||||
</p>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>联系电话</label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
<span className="material-symbols-outlined">phone_in_talk</span>
|
||||
<input
|
||||
disabled={readOnly}
|
||||
type="tel"
|
||||
placeholder="手机号或座机,如 0379-8888888"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
店长或对外展示号码,用户端拨号使用此号码,支持座机。
|
||||
</p>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店地址</label>
|
||||
@@ -409,26 +431,13 @@ export default function StoreDetailPage() {
|
||||
</div>
|
||||
{canMutate && !readOnly ? (
|
||||
<>
|
||||
<div className="partner-upload-grid">
|
||||
{envPhotoUrls.map((url, index) => (
|
||||
<OssUploadField
|
||||
key={index}
|
||||
compact
|
||||
bizType="STORE_ENV"
|
||||
mediaType="IMAGE"
|
||||
value={url}
|
||||
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ width: '100%', marginTop: 12 }}
|
||||
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
|
||||
>
|
||||
添加环境照片
|
||||
</button>
|
||||
<MultiOssUploadField
|
||||
bizType="STORE_ENV"
|
||||
maxCount={20}
|
||||
value={uniqueEnvUrls(envPhotoUrls)}
|
||||
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
|
||||
label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20)`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
|
||||
@@ -8,9 +8,7 @@ import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAcce
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
storeAuditLabel,
|
||||
storeAuditPillClass,
|
||||
storeStatusLabel,
|
||||
storeStatusPillClass,
|
||||
storeListBadge,
|
||||
type StoreStatusValue,
|
||||
} from '../lib/storeStatus';
|
||||
import { usePartnerPageView } from '../lib/usePageView';
|
||||
@@ -20,10 +18,10 @@ type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
|
||||
const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'OPEN', label: '营业中' },
|
||||
{ key: 'PAUSED', label: '暂时闭店' },
|
||||
{ key: 'PAUSED', label: '临时闭店' },
|
||||
{ key: 'PENDING_AUDIT', label: '待审核' },
|
||||
{ key: 'REJECTED', label: '已驳回' },
|
||||
{ key: 'CLOSED', label: '关闭' },
|
||||
{ key: 'CLOSED', label: '永久闭店' },
|
||||
];
|
||||
|
||||
export default function StoreListPage() {
|
||||
@@ -46,7 +44,10 @@ export default function StoreListPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
void loadStores();
|
||||
}, [navigate, loadStores]);
|
||||
|
||||
@@ -54,20 +55,28 @@ export default function StoreListPage() {
|
||||
document.title = canMutate ? '门店管理' : '我的门店';
|
||||
}, [canMutate]);
|
||||
|
||||
const filtered = useMemo(() => stores.filter((s) => {
|
||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
const status = String(s.status).toUpperCase();
|
||||
let matchStatus = true;
|
||||
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
|
||||
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
||||
else if (filter !== 'ALL') matchStatus = status === filter;
|
||||
return matchQ && matchStatus;
|
||||
}), [stores, q, filter]);
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
stores.filter((s) => {
|
||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
const status = String(s.status).toUpperCase();
|
||||
let matchStatus = true;
|
||||
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
|
||||
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
||||
else if (filter !== 'ALL') matchStatus = status === filter;
|
||||
return matchQ && matchStatus;
|
||||
}),
|
||||
[stores, q, filter],
|
||||
);
|
||||
|
||||
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
|
||||
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
||||
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
|
||||
setError(
|
||||
auditStatus === 'REJECTED'
|
||||
? '门店审核未通过,请查看驳回原因并修改后重新提交'
|
||||
: '门店尚在总部审核中,通过后方可开门',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
@@ -82,7 +91,8 @@ export default function StoreListPage() {
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await loadStores();
|
||||
if (next === 'OPEN') toastSuccess('开店成功');
|
||||
if (next === 'OPEN') toastSuccess('已营业');
|
||||
if (next === 'PAUSED') toastSuccess('已临时闭店');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
@@ -102,6 +112,7 @@ export default function StoreListPage() {
|
||||
body: JSON.stringify({ status: 'CLOSED' }),
|
||||
});
|
||||
await loadStores();
|
||||
toastSuccess('已永久闭店');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
@@ -111,29 +122,43 @@ export default function StoreListPage() {
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||
{error && (
|
||||
<p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="partner-sticky-filter">
|
||||
<div className="partner-search">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div className="partner-chips">
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="partner-store-filter-row">
|
||||
<label className="partner-store-filter-label" htmlFor="partner-store-status-filter">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
id="partner-store-status-filter"
|
||||
className="partner-store-filter-select"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as StatusFilter)}
|
||||
>
|
||||
{FILTERS.map((f) => (
|
||||
<option key={f.key} value={f.key}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
<Link to="/stores/new" className="partner-fab-link">
|
||||
<button type="button" className="partner-btn-primary">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
录入新门店
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/stores/new" className="partner-fab-link">
|
||||
<button type="button" className="partner-btn-primary">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
录入新门店
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||
@@ -145,87 +170,114 @@ export default function StoreListPage() {
|
||||
const dim = currentStatus === 'CLOSED';
|
||||
const storeName = String(s.name || '未命名门店');
|
||||
const busy = updatingId === storeId;
|
||||
const canOpen = canPartnerOpenStore(auditStatus);
|
||||
const badge = storeListBadge(s);
|
||||
const switchOn = currentStatus === 'OPEN';
|
||||
const switchDisabled =
|
||||
busy ||
|
||||
currentStatus === 'CLOSED' ||
|
||||
auditStatus === 'PENDING' ||
|
||||
auditStatus === 'REJECTED';
|
||||
return (
|
||||
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<Link
|
||||
to={`/stores/${storeId}`}
|
||||
className="partner-store-card-hit"
|
||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||
>
|
||||
<div className="partner-store-card-header">
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>门店名称</p>
|
||||
<div className="partner-store-card-main">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>
|
||||
门店名称
|
||||
</p>
|
||||
<h3 className="headline-md">{storeName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
||||
{auditStatus !== 'APPROVED' && (
|
||||
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}>
|
||||
{storeAuditLabel(auditStatus)}
|
||||
{auditStatus === 'REJECTED' && s.rejectReason ? `:${String(s.rejectReason)}` : ''}
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>
|
||||
{String(s.address || s.district || '')}
|
||||
</p>
|
||||
{auditStatus === 'REJECTED' && s.rejectReason ? (
|
||||
<p
|
||||
className="label-md"
|
||||
style={{ marginTop: 8, color: 'var(--color-heritage-red)' }}
|
||||
>
|
||||
{storeAuditLabel(auditStatus)}:{String(s.rejectReason)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||
{auditStatus !== 'APPROVED' ? (
|
||||
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
|
||||
{storeAuditLabel(auditStatus)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
|
||||
{storeStatusLabel(currentStatus)}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<span className={`partner-status-pill partner-store-card-badge ${badge.pillClass}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
{canMutate && (
|
||||
<div className="partner-store-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
|
||||
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
|
||||
>
|
||||
暂时闭店
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
|
||||
disabled={busy || currentStatus === 'CLOSED'}
|
||||
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
{currentStatus === 'PAUSED' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||
disabled={busy || !canOpen}
|
||||
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
|
||||
{canMutate ? (
|
||||
<div className="partner-store-card-actions partner-store-card-actions--v3416">
|
||||
{currentStatus !== 'CLOSED' ? (
|
||||
<>
|
||||
<label
|
||||
className={`partner-store-switch${switchDisabled ? ' partner-store-switch--disabled' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={switchOn}
|
||||
disabled={switchDisabled}
|
||||
aria-label={switchOn ? '营业中,点击临时闭店' : '临时闭店,点击营业'}
|
||||
onChange={() => {
|
||||
const next: StoreStatusValue = switchOn ? 'PAUSED' : 'OPEN';
|
||||
void updateStatus(storeId, next, auditStatus);
|
||||
}}
|
||||
/>
|
||||
<span className="partner-store-switch-track" aria-hidden />
|
||||
<span className="partner-store-switch-text">
|
||||
{switchOn ? '开启(营业中)' : '关闭(临时闭店)'}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-store-close-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
||||
>
|
||||
永久闭店
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<Link
|
||||
to={`/stores/${storeId}`}
|
||||
className="partner-menu-icon"
|
||||
style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}
|
||||
>
|
||||
{canOpen ? '开门营业' : '待审核通过'}
|
||||
</button>
|
||||
)}
|
||||
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||||
edit
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{closeTarget && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
|
||||
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||
<div
|
||||
className="partner-ship-modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setCloseTarget(null)}
|
||||
>
|
||||
<div
|
||||
className="partner-ship-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>
|
||||
确认永久闭店?
|
||||
</h3>
|
||||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||
关闭后不可恢复营业,确认关闭该门店?
|
||||
永久闭店后不可再开门营业,确认关闭该门店?
|
||||
</p>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||
确认关闭
|
||||
确认永久闭店
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import StorePackagesForm from '../components/StorePackagesForm';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
@@ -30,7 +31,18 @@ export default function StorePackagesPage() {
|
||||
: data.live?.length
|
||||
? data.live
|
||||
: [emptyPackage()];
|
||||
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
||||
setItems(
|
||||
base.map((p, i) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||
return {
|
||||
...p,
|
||||
price: String(p.price),
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
imageUrls,
|
||||
sortOrder: i,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setPending(data.pendingRequest ?? null);
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||
|
||||
@@ -304,6 +304,30 @@ html {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 卡片标题旁的紧凑型描边按钮:不拉伸、缩小尺寸、贴右对齐 */
|
||||
.partner-btn-outline--compact {
|
||||
flex: none;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-outline);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-label);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.partner-btn-outline--compact:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.partner-btn-outline--compact:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.partner-btn-wechat {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
@@ -1960,6 +1984,134 @@ nav.app-tabbar .app-tabbar-label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.partner-store-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.partner-store-filter-label {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-label);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
|
||||
.partner-store-filter-select {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-container-low);
|
||||
font-size: 14px;
|
||||
color: var(--color-ink-black);
|
||||
}
|
||||
|
||||
.partner-store-filter-select:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(166, 29, 36, 0.2);
|
||||
}
|
||||
|
||||
.partner-store-card-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.partner-store-card-badge {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.partner-store-card-actions--v3416 {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.partner-store-card-actions--v3416 > .partner-store-switch {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.partner-store-card-actions--v3416 > .partner-store-close-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.partner-store-card-actions--v3416 > .partner-menu-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.partner-store-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-label);
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.partner-store-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.partner-store-switch-track {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-container-highest);
|
||||
transition: background 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partner-store-switch-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.partner-store-switch input:checked + .partner-store-switch-track {
|
||||
background: var(--color-success-green);
|
||||
}
|
||||
|
||||
.partner-store-switch input:checked + .partner-store-switch-track::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.partner-store-switch--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.partner-store-close-btn {
|
||||
flex: 0 0 auto !important;
|
||||
padding: 8px 12px !important;
|
||||
border: 1px solid rgba(166, 29, 36, 0.35);
|
||||
background: transparent;
|
||||
color: var(--color-heritage-red);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.partner-store-close-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.partner-store-card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -2061,6 +2213,39 @@ nav.app-tabbar .app-tabbar-label {
|
||||
margin: 0 var(--space-page) var(--space-md);
|
||||
}
|
||||
|
||||
.partner-onboard-cs-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.partner-onboard-cs-qr {
|
||||
display: block;
|
||||
width: min(220px, 70vw);
|
||||
height: auto;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-surface-container);
|
||||
}
|
||||
|
||||
.partner-onboard-cs-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-label);
|
||||
font-size: 14px;
|
||||
color: var(--color-ink-black);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.partner-onboard-cs-check input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Store detail ── */
|
||||
.partner-detail-page {
|
||||
padding-bottom: 96px;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
|
||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"],
|
||||
"@dukang/domain": ["../../packages/domain/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -9,12 +9,14 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5175,
|
||||
proxy: {
|
||||
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3000',
|
||||
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
||||
STORE_PACKAGE_MAX_COUNT,
|
||||
} from '@dukang/shared-types';
|
||||
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
|
||||
type Props = {
|
||||
items: PackageFormItem[];
|
||||
@@ -10,6 +14,37 @@ type Props = {
|
||||
|
||||
export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||
|
||||
async function pickPackageImages(pkgIndex: number, fileList?: FileList | null) {
|
||||
if (!fileList?.length || disabled) return;
|
||||
const current = items[pkgIndex];
|
||||
const existing =
|
||||
Array.isArray(current.imageUrls) && current.imageUrls.length > 0
|
||||
? current.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
|
||||
: current.imageUrl
|
||||
? [String(current.imageUrl)]
|
||||
: [];
|
||||
const room = Math.max(0, STORE_PACKAGE_IMAGE_MAX_COUNT - existing.length);
|
||||
const files = Array.from(fileList).slice(0, room);
|
||||
if (!files.length) return;
|
||||
|
||||
setUploadingIndex(pkgIndex);
|
||||
try {
|
||||
const appended: string[] = [];
|
||||
for (const file of files) {
|
||||
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
|
||||
appended.push(result.url);
|
||||
}
|
||||
const next = [...existing, ...appended];
|
||||
updateAt(pkgIndex, { imageUrls: next, imageUrl: next[0] ?? '' });
|
||||
} finally {
|
||||
setUploadingIndex(null);
|
||||
const input = fileRefs.current[pkgIndex];
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageFormItem>) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
@@ -44,6 +79,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
{list.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
const filled =
|
||||
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
|
||||
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
|
||||
: item.imageUrl
|
||||
? [String(item.imageUrl)]
|
||||
: [];
|
||||
return (
|
||||
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
|
||||
<div className="shop-packages-card-head">
|
||||
@@ -69,7 +110,7 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">套餐名称 *</span>
|
||||
<span className="shop-packages-label">套餐名称</span>
|
||||
<input
|
||||
className="shop-packages-input"
|
||||
placeholder="如:套餐A"
|
||||
@@ -80,12 +121,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
</label>
|
||||
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">价格(元) *</span>
|
||||
<span className="shop-packages-label">价格(元)</span>
|
||||
<input
|
||||
className="shop-packages-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
step="0.01"
|
||||
placeholder="198"
|
||||
value={item.price}
|
||||
disabled={disabled}
|
||||
@@ -94,10 +135,10 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
</label>
|
||||
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">菜品 *</span>
|
||||
<span className="shop-packages-label">菜品</span>
|
||||
<textarea
|
||||
className="shop-packages-textarea"
|
||||
rows={3}
|
||||
className="shop-packages-input"
|
||||
rows={2}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
value={item.dishes}
|
||||
disabled={disabled}
|
||||
@@ -107,8 +148,9 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">使用时间</span>
|
||||
<input
|
||||
<textarea
|
||||
className="shop-packages-input"
|
||||
rows={2}
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
@@ -116,10 +158,65 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="shop-packages-field">
|
||||
<span className="shop-packages-label">
|
||||
套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量)
|
||||
</span>
|
||||
{filled.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{filled.map((url, imgIndex) => (
|
||||
<div key={`${url}-${imgIndex}`} style={{ position: 'relative', width: 88 }}>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-packages-remove"
|
||||
style={{ position: 'absolute', top: 2, right: 2, margin: 0 }}
|
||||
onClick={() => {
|
||||
const next = filled.filter((_, i) => i !== imgIndex);
|
||||
updateAt(index, { imageUrls: next, imageUrl: next[0] ?? '' });
|
||||
}}
|
||||
>
|
||||
删
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileRefs.current[index] = el;
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
disabled={disabled || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||
onChange={(e) => void pickPackageImages(index, e.target.files)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-packages-add"
|
||||
style={{ marginTop: 0 }}
|
||||
disabled={disabled || uploadingIndex === index || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||
onClick={() => fileRefs.current[index]?.click()}
|
||||
>
|
||||
{uploadingIndex === index
|
||||
? '上传中…'
|
||||
: `批量上传(${filled.length}/${STORE_PACKAGE_IMAGE_MAX_COUNT})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="shop-packages-field">
|
||||
<span className="shop-packages-label">其他说明</span>
|
||||
<input
|
||||
<textarea
|
||||
className="shop-packages-input"
|
||||
rows={2}
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -2,7 +2,10 @@ type WechatScanAuthModalProps = {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
|
||||
mode?: 'bind' | 'recover';
|
||||
onAuthorize: () => void;
|
||||
onRefresh?: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
@@ -10,28 +13,43 @@ export default function WechatScanAuthModal({
|
||||
open,
|
||||
loading,
|
||||
error,
|
||||
mode = 'bind',
|
||||
onAuthorize,
|
||||
onRefresh,
|
||||
onCancel,
|
||||
}: WechatScanAuthModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const isRecover = mode === 'recover';
|
||||
|
||||
return (
|
||||
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
|
||||
<div className="shop-scan-auth-card">
|
||||
<div className="shop-scan-auth-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">微信授权</h2>
|
||||
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
|
||||
{isRecover ? '扫码能力未就绪' : '微信授权'}
|
||||
</h2>
|
||||
<p className="shop-scan-auth-desc">
|
||||
扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。
|
||||
{isRecover
|
||||
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
|
||||
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
|
||||
</p>
|
||||
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
|
||||
<div className="shop-scan-auth-actions">
|
||||
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</button>
|
||||
{isRecover && onRefresh ? (
|
||||
<button type="button" className="shop-scan-auth-confirm" onClick={onRefresh} disabled={loading}>
|
||||
刷新页面
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
|
||||
{loading ? '跳转授权中…' : '微信授权'}
|
||||
{loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
fetchClientConfig,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatCallbackOnce,
|
||||
handleShopWechatLoginResult,
|
||||
isShopWechatUnboundError,
|
||||
SHOP_WX_NEED_PHONE_LOGIN_MSG,
|
||||
@@ -79,7 +80,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const config = await fetchClientConfig();
|
||||
if (isWxAuthorizeEnabled(config)) {
|
||||
const result = await handleShopWechatCallback();
|
||||
const result = await handleShopWechatCallbackOnce();
|
||||
if (result && !cancelled) {
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) applySession(session);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reportApiError } from '@dukang/client-logging';
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
|
||||
@@ -213,6 +215,12 @@ async function rawRequest<T>(
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = res.status >= 500 ? res.status : json.code;
|
||||
if (json.code === 400) {
|
||||
reportApiError(
|
||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { isIosDevice } from '@dukang/weixin-sdk';
|
||||
|
||||
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
|
||||
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||
|
||||
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
|
||||
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
|
||||
|
||||
export function markPendingScanAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function peekPendingScanAfterAuth(): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(SHOP_PENDING_SCAN_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingScanAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(SHOP_PENDING_SCAN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth(含短信登录后绑微信)回跳后,标记下一次扫码需要加长预热 */
|
||||
export function markScanWarmupAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_SCAN_WARMUP_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeScanWarmupAfterAuth(): boolean {
|
||||
try {
|
||||
if (sessionStorage.getItem(SHOP_SCAN_WARMUP_KEY) !== '1') return false;
|
||||
sessionStorage.removeItem(SHOP_SCAN_WARMUP_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth 回跳后延迟再调 scanQRCode(iOS JSSDK 离线校验更慢) */
|
||||
export function getPostAuthScanDelayMs(): number {
|
||||
return isIosDevice() ? 1200 : 600;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import {
|
||||
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
||||
STORE_PACKAGE_MAX_COUNT,
|
||||
normalizeStorePackageImageUrls,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PackageFormItem = StorePackageItemDto;
|
||||
|
||||
@@ -10,21 +14,36 @@ export function emptyPackage(index = 0): PackageFormItem {
|
||||
dishes: '',
|
||||
usableTime: '',
|
||||
otherNotes: '',
|
||||
imageUrl: '',
|
||||
imageUrls: [],
|
||||
sortOrder: index,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||
return raw
|
||||
.map((item, index) => ({
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || '',
|
||||
otherNotes: item.otherNotes?.trim() || '',
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes);
|
||||
.map((item, index) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls(item);
|
||||
return {
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || '',
|
||||
otherNotes: item.otherNotes?.trim() || '',
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
imageUrls,
|
||||
sortOrder: index,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item) =>
|
||||
item.name ||
|
||||
item.price ||
|
||||
item.dishes ||
|
||||
item.usableTime ||
|
||||
item.otherNotes ||
|
||||
item.imageUrls.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
|
||||
@@ -38,6 +57,15 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
||||
return `第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatPackagePrice(price: string | number) {
|
||||
const n = typeof price === 'number' ? price : Number(price);
|
||||
if (!Number.isFinite(n)) return String(price);
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(2);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { apiBase } from './api';
|
||||
import { getStoreProfile } from './api';
|
||||
import {
|
||||
compressImageFileIfNeeded,
|
||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||
formatOssMaxSizeMb,
|
||||
} from '@dukang/shared-ui/compressImage';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -15,9 +20,13 @@ export type RegisteredResource = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
|
||||
export async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
|
||||
const compressed = await compressImageFileIfNeeded(file);
|
||||
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', compressed);
|
||||
formData.append('bizType', bizType);
|
||||
formData.append('mediaType', 'IMAGE');
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { markScanWarmupAfterAuth } from './shop-scan-auth';
|
||||
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
||||
|
||||
export type ShopAccountProfile = {
|
||||
@@ -138,6 +139,20 @@ export async function handleShopWechatCallback(): Promise<WechatLoginResult | nu
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
let oauthCallbackInflight: Promise<WechatLoginResult | null> | null = null;
|
||||
|
||||
/** 全局单例:避免 Context 与页面 effect 重复消费 OAuth code */
|
||||
export async function handleShopWechatCallbackOnce(): Promise<WechatLoginResult | null> {
|
||||
if (!isWechatEnv()) return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.get('code')) return null;
|
||||
if (oauthCallbackInflight) return oauthCallbackInflight;
|
||||
oauthCallbackInflight = handleShopWechatCallback().finally(() => {
|
||||
oauthCallbackInflight = null;
|
||||
});
|
||||
return oauthCallbackInflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信一键登录(已绑定微信的门店账号免验证码)。
|
||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||
@@ -148,15 +163,19 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
markScanWarmupAfterAuth();
|
||||
const result = await weixinSdk.login();
|
||||
if (result) return handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
|
||||
export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
if (!isWxAuthorizeEnabled(config)) return 'skipped';
|
||||
if (!isWechatEnv()) return 'skipped';
|
||||
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
|
||||
if (session?.account?.hasWechat) return 'skipped';
|
||||
markScanWarmupAfterAuth();
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
@@ -166,5 +185,6 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
markScanWarmupAfterAuth();
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
@@ -1,285 +1,680 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
|
||||
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
||||
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
|
||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||
|
||||
import {
|
||||
|
||||
authorizeShopWechat,
|
||||
|
||||
checkNeedsWechatAuth,
|
||||
|
||||
fetchShopAccount,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
|
||||
} from '../lib/wechat-auth';
|
||||
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
|
||||
import {
|
||||
|
||||
clearPendingScanAfterAuth,
|
||||
|
||||
consumeScanWarmupAfterAuth,
|
||||
|
||||
getPostAuthScanDelayMs,
|
||||
|
||||
markPendingScanAfterAuth,
|
||||
|
||||
peekPendingScanAfterAuth,
|
||||
|
||||
} from '../lib/shop-scan-auth';
|
||||
|
||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
import { trackStore } from '../lib/analytics';
|
||||
|
||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||
|
||||
|
||||
function formatMoney(n: number) {
|
||||
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
|
||||
}
|
||||
|
||||
function formatScanError(e: unknown): string {
|
||||
|
||||
|
||||
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||
|
||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
|
||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||
|
||||
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
|
||||
|
||||
}
|
||||
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
||||
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
||||
|
||||
if (isScanPermissionWarmupError(msg)) {
|
||||
|
||||
if (opts?.afterAuth) {
|
||||
|
||||
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
|
||||
|
||||
}
|
||||
|
||||
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
|
||||
|
||||
}
|
||||
|
||||
return msg;
|
||||
|
||||
}
|
||||
|
||||
function isScanRecoverableError(msg: string): boolean {
|
||||
|
||||
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function HomePage() {
|
||||
|
||||
useStorePageView('store_home_view');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { ready, authenticated } = useStoreSession();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
const [scanMsg, setScanMsg] = useState('');
|
||||
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
|
||||
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
|
||||
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
const [showOpenModal, setShowOpenModal] = useState(false);
|
||||
|
||||
const [opening, setOpening] = useState(false);
|
||||
|
||||
const pendingScanStartedRef = useRef(false);
|
||||
|
||||
|
||||
|
||||
const loadDashboard = useCallback(() => {
|
||||
|
||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||
|
||||
.then((d) => {
|
||||
|
||||
setDash(d);
|
||||
|
||||
})
|
||||
|
||||
.catch(() => {});
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
}, [loadDashboard]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
function onResume() {
|
||||
|
||||
setScanning(false);
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
|
||||
if (document.visibilityState === 'visible') onResume();
|
||||
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
window.addEventListener('pageshow', onResume);
|
||||
|
||||
window.addEventListener('focus', onResume);
|
||||
|
||||
return () => {
|
||||
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
|
||||
window.removeEventListener('pageshow', onResume);
|
||||
|
||||
window.removeEventListener('focus', onResume);
|
||||
|
||||
};
|
||||
|
||||
}, [loadDashboard]);
|
||||
|
||||
async function runScan(opts?: { postAuthWarmup?: boolean }) {
|
||||
trackStore('store_redeem_scan_start');
|
||||
if (!isWechatEnv()) {
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
setScanMsg('');
|
||||
try {
|
||||
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
|
||||
if (opts?.postAuthWarmup) {
|
||||
weixinSdk.reset();
|
||||
}
|
||||
await weixinSdk.init();
|
||||
const raw = await weixinSdk.scanQrCode(
|
||||
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
|
||||
);
|
||||
if (!raw) {
|
||||
void loadDashboard();
|
||||
|
||||
|
||||
const runScan = useCallback(
|
||||
|
||||
async (opts?: { postAuthWarmup?: boolean }) => {
|
||||
|
||||
trackStore('store_redeem_scan_start');
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
const token = parseRedeemTokenFromScan(raw);
|
||||
if (!token) {
|
||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||
return;
|
||||
|
||||
setScanning(true);
|
||||
|
||||
if (!opts?.postAuthWarmup) {
|
||||
|
||||
setScanMsg('');
|
||||
|
||||
}
|
||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||
} catch (e) {
|
||||
setScanMsg(formatScanError(e));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL)
|
||||
if (opts?.postAuthWarmup || isIosDevice()) {
|
||||
|
||||
weixinSdk.reset();
|
||||
|
||||
}
|
||||
|
||||
await weixinSdk.init();
|
||||
|
||||
const raw = await weixinSdk.scanQrCode(
|
||||
|
||||
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
|
||||
|
||||
);
|
||||
|
||||
if (!raw) {
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const token = parseRedeemTokenFromScan(raw);
|
||||
|
||||
if (!token) {
|
||||
|
||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||
|
||||
} catch (e) {
|
||||
|
||||
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
|
||||
|
||||
setScanMsg(tip);
|
||||
|
||||
if (isScanRecoverableError(tip)) {
|
||||
|
||||
setAuthModalMode('recover');
|
||||
|
||||
setAuthError(tip);
|
||||
|
||||
setAuthModalOpen(true);
|
||||
|
||||
}
|
||||
|
||||
} finally {
|
||||
|
||||
setScanning(false);
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
[loadDashboard, navigate],
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !searchParams.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
}
|
||||
setAuthModalOpen(false);
|
||||
setAuthError('');
|
||||
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
if (shouldScan) {
|
||||
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
|
||||
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
});
|
||||
}, [searchParams, applySession, setSearchParams]);
|
||||
|
||||
if (!ready || !authenticated || !isWechatEnv()) return;
|
||||
|
||||
if (searchParams.get('code')) return;
|
||||
|
||||
if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
|
||||
|
||||
|
||||
|
||||
pendingScanStartedRef.current = true;
|
||||
|
||||
clearPendingScanAfterAuth();
|
||||
|
||||
setAuthModalOpen(false);
|
||||
|
||||
setAuthLoading(false);
|
||||
|
||||
setAuthError('');
|
||||
|
||||
setScanMsg('微信授权成功,正在准备扫码…');
|
||||
|
||||
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
|
||||
void runScan({ postAuthWarmup: true });
|
||||
|
||||
}, getPostAuthScanDelayMs());
|
||||
|
||||
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
|
||||
}, [ready, authenticated, searchParams, runScan]);
|
||||
|
||||
|
||||
|
||||
async function handleScan() {
|
||||
|
||||
setScanMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
|
||||
// 门店临时闭店/休息中(非营业状态)时,点击扫码直接提示,不进入扫码流程
|
||||
if (status !== 'OPEN') {
|
||||
setShowOpenModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const profile = await fetchShopAccount();
|
||||
|
||||
if (await checkNeedsWechatAuth(profile)) {
|
||||
|
||||
pendingScanStartedRef.current = false;
|
||||
|
||||
setAuthModalMode('bind');
|
||||
|
||||
setAuthError('');
|
||||
|
||||
setAuthModalOpen(true);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
await runScan();
|
||||
|
||||
const needWarmup = consumeScanWarmupAfterAuth();
|
||||
|
||||
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
|
||||
|
||||
} catch (e) {
|
||||
|
||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function openStoreAndContinue() {
|
||||
if (opening) return;
|
||||
setOpening(true);
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/store/status', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'OPEN' }),
|
||||
});
|
||||
setShowOpenModal(false);
|
||||
setScanMsg('');
|
||||
void loadDashboard(); // 刷新门店状态为营业中,扫码按钮可再次使用
|
||||
} catch (e) {
|
||||
setShowOpenModal(false);
|
||||
setScanMsg(e instanceof Error ? e.message : '开启营业失败');
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startWechatAuth() {
|
||||
|
||||
setAuthLoading(true);
|
||||
|
||||
setAuthError('');
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
||||
|
||||
pendingScanStartedRef.current = false;
|
||||
|
||||
markPendingScanAfterAuth();
|
||||
|
||||
await authorizeShopWechat();
|
||||
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
|
||||
clearPendingScanAfterAuth();
|
||||
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
|
||||
setAuthLoading(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const store = dash?.store as Record<string, unknown> | undefined;
|
||||
|
||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||
|
||||
const status = String(store?.status || '');
|
||||
|
||||
const open = status === 'OPEN';
|
||||
|
||||
const hoursParts: string[] = [];
|
||||
|
||||
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||
|
||||
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||
|
||||
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
||||
|
||||
const statusText =
|
||||
|
||||
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||
|
||||
<header className="shop-home-header">
|
||||
|
||||
<h1 className="app-page-title">门店管理中心</h1>
|
||||
|
||||
</header>
|
||||
|
||||
|
||||
|
||||
<div className="shop-home-content">
|
||||
|
||||
<section className="shop-home-hero">
|
||||
|
||||
<div className="shop-home-hero-store">
|
||||
|
||||
<span className="material-symbols-outlined shop-fill-icon">store</span>
|
||||
|
||||
<h2>{String(store?.name || '门店')}</h2>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="shop-home-stats">
|
||||
|
||||
<div className="shop-home-stat">
|
||||
|
||||
<p className="shop-home-stat-label">今日核销笔数</p>
|
||||
|
||||
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
|
||||
|
||||
<p className="shop-home-stat-sub">
|
||||
|
||||
扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)}
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="shop-home-stat">
|
||||
|
||||
<p className="shop-home-stat-label">今日到账金额</p>
|
||||
|
||||
<p className="shop-home-stat-value">
|
||||
|
||||
<span style={{ fontSize: 18 }}>¥</span>
|
||||
|
||||
{formatMoney(Number(dash?.todayAmount || 0))}
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section className="shop-home-scan">
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="shop-home-scan-btn"
|
||||
|
||||
disabled={scanning}
|
||||
|
||||
onClick={() => void handleScan()}
|
||||
|
||||
>
|
||||
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
|
||||
</button>
|
||||
|
||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
||||
|
||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
||||
|
||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
||||
|
||||
<span className="material-symbols-outlined">smartphone</span>
|
||||
|
||||
手机号核销
|
||||
|
||||
</Link>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section className="shop-home-status">
|
||||
|
||||
<div className="shop-home-status-left">
|
||||
|
||||
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
|
||||
|
||||
<span className="material-symbols-outlined shop-fill-icon">schedule</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<p className="shop-home-status-title">营业状态</p>
|
||||
|
||||
<p className="shop-home-status-sub">{statusText}</p>
|
||||
|
||||
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||
|
||||
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
|
||||
|
||||
<span className="shop-home-switch-track" />
|
||||
|
||||
</label>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section>
|
||||
|
||||
<div className="shop-home-records-head">
|
||||
|
||||
<h3 className="shop-home-records-title">核销记录</h3>
|
||||
|
||||
<Link to="/records" className="shop-home-records-link">
|
||||
|
||||
查看全部
|
||||
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
||||
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="shop-home-record-list">
|
||||
|
||||
{recent.length === 0 && (
|
||||
|
||||
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}>暂无核销记录</p>
|
||||
|
||||
)}
|
||||
|
||||
{recent.map((r) => (
|
||||
|
||||
<div key={String(r.id)} className="shop-home-record-item">
|
||||
|
||||
<div>
|
||||
|
||||
<p className="shop-home-record-time">核销时间</p>
|
||||
|
||||
<p className="shop-home-record-value">
|
||||
|
||||
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
||||
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<WechatScanAuthModal
|
||||
|
||||
open={authModalOpen}
|
||||
|
||||
mode={authModalMode}
|
||||
|
||||
loading={authLoading}
|
||||
|
||||
error={authError}
|
||||
|
||||
onAuthorize={() => void startWechatAuth()}
|
||||
onCancel={() => {
|
||||
setAuthModalOpen(false);
|
||||
setAuthError('');
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
|
||||
onRefresh={() => {
|
||||
|
||||
window.location.reload();
|
||||
|
||||
}}
|
||||
|
||||
onCancel={() => {
|
||||
|
||||
setAuthModalOpen(false);
|
||||
|
||||
setAuthModalMode('bind');
|
||||
|
||||
setAuthError('');
|
||||
|
||||
clearPendingScanAfterAuth();
|
||||
|
||||
pendingScanStartedRef.current = false;
|
||||
|
||||
}}
|
||||
|
||||
/>
|
||||
|
||||
{showOpenModal && (
|
||||
<div className="shop-redeem-modal" role="dialog" aria-modal="true">
|
||||
<div className="shop-redeem-modal-card">
|
||||
<h4 className="shop-redeem-modal-title">门店休息中</h4>
|
||||
<p className="shop-redeem-modal-desc">门店目前休息中无法核销,是否开启营业?</p>
|
||||
<div className="shop-redeem-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-modal-cancel"
|
||||
disabled={opening}
|
||||
onClick={() => setShowOpenModal(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-modal-confirm"
|
||||
disabled={opening}
|
||||
onClick={() => void openStoreAndContinue()}
|
||||
>
|
||||
{opening ? '开启中…' : '确认开启'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</PullToRefresh>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
consumeShopWechatLoginHint,
|
||||
fetchClientConfig,
|
||||
formatShopWechatError,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatCallbackOnce,
|
||||
handleShopWechatLoginResult,
|
||||
loginShopWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
@@ -88,7 +88,7 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
void handleShopWechatCallbackOnce()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
@@ -140,18 +140,28 @@ export default function LoginPage() {
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
const trimmedPhone = phone.trim();
|
||||
const trimmedCode = code.trim();
|
||||
if (!trimmedPhone) {
|
||||
setMsg('phone should not be empty');
|
||||
return;
|
||||
}
|
||||
if (!trimmedCode) {
|
||||
setMsg('code should not be empty');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
|
||||
});
|
||||
saveRememberedSession(data);
|
||||
applySession(data);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindShopWechatAfterSmsLogin();
|
||||
await bindShopWechatAfterSmsLogin(data);
|
||||
return;
|
||||
}
|
||||
routeAfterShopLogin(data, navigate);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
authorizeShopWechat,
|
||||
fetchClientConfig,
|
||||
fetchShopAccount,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatCallbackOnce,
|
||||
handleShopWechatLoginResult,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
@@ -45,7 +45,7 @@ export default function MinePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
void handleShopWechatCallbackOnce()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import ShopPackagesForm from '../components/ShopPackagesForm';
|
||||
import { request } from '../lib/api';
|
||||
@@ -30,7 +31,18 @@ export default function PackagesPage() {
|
||||
: data.live?.length
|
||||
? data.live
|
||||
: [emptyPackage()];
|
||||
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
||||
setItems(
|
||||
base.map((p, i) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||
return {
|
||||
...p,
|
||||
price: String(p.price),
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
imageUrls,
|
||||
sortOrder: i,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setPending(data.pendingRequest ?? null);
|
||||
setMsg('');
|
||||
} catch (e) {
|
||||
|
||||
@@ -20,6 +20,8 @@ export default function PhoneRedeemPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||
const [showOpenModal, setShowOpenModal] = useState(false);
|
||||
const [opening, setOpening] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
@@ -36,15 +38,7 @@ export default function PhoneRedeemPage() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [confirmCooldown]);
|
||||
|
||||
async function sendConfirmSms() {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
}
|
||||
async function prepareDirectRedeem() {
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
setMsg('请输入有效核销金额');
|
||||
@@ -69,6 +63,37 @@ export default function PhoneRedeemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendConfirmSms() {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (storeClosed) {
|
||||
setShowOpenModal(true);
|
||||
return;
|
||||
}
|
||||
await prepareDirectRedeem();
|
||||
}
|
||||
|
||||
async function openStoreAndContinue() {
|
||||
if (opening) return;
|
||||
setOpening(true);
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/store/status', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'OPEN' }),
|
||||
});
|
||||
setStoreClosed(false);
|
||||
setShowOpenModal(false);
|
||||
await prepareDirectRedeem();
|
||||
} catch (e) {
|
||||
setShowOpenModal(false);
|
||||
setMsg(e instanceof Error ? e.message : '开启营业失败');
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
if (!prepared) {
|
||||
setMsg('请先发送核销验证码');
|
||||
@@ -114,7 +139,7 @@ export default function PhoneRedeemPage() {
|
||||
|
||||
<main className="shop-redeem-main">
|
||||
{storeClosed && (
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店当前未营业,无法核销</p>
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店目前休息中无法核销,开启营业后可继续</p>
|
||||
)}
|
||||
|
||||
<section className="shop-redeem-card">
|
||||
@@ -181,7 +206,7 @@ export default function PhoneRedeemPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-code-btn"
|
||||
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
|
||||
disabled={loading || confirmCooldown > 0 || !canSendCode}
|
||||
onClick={() => void sendConfirmSms()}
|
||||
>
|
||||
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
||||
@@ -205,6 +230,33 @@ export default function PhoneRedeemPage() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{showOpenModal && (
|
||||
<div className="shop-redeem-modal" role="dialog" aria-modal="true">
|
||||
<div className="shop-redeem-modal-card">
|
||||
<h4 className="shop-redeem-modal-title">门店休息中</h4>
|
||||
<p className="shop-redeem-modal-desc">门店目前休息中无法核销,是否开启营业?</p>
|
||||
<div className="shop-redeem-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-modal-cancel"
|
||||
disabled={opening}
|
||||
onClick={() => setShowOpenModal(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-modal-confirm"
|
||||
disabled={opening}
|
||||
onClick={() => void openStoreAndContinue()}
|
||||
>
|
||||
{opening ? '开启中…' : '确认开启'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export default function RedeemConfirmPage() {
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [failCount, setFailCount] = useState(0);
|
||||
const [showWeakNet, setShowWeakNet] = useState(false);
|
||||
const [showOpenModal, setShowOpenModal] = useState(false);
|
||||
const [opening, setOpening] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
@@ -48,34 +50,37 @@ export default function RedeemConfirmPage() {
|
||||
setToken(scanned);
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPreview() {
|
||||
if (!token.trim()) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
request<Preview>('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(async (e) => {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
const report = await reportRedeemFailure(token, 'preview', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
setShowWeakNet(true);
|
||||
} else if (report) {
|
||||
setFailCount(report.failCount);
|
||||
}
|
||||
try {
|
||||
const p = await request<Preview>('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
setPreview(p);
|
||||
setMsg('');
|
||||
} catch (e) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
const report = await reportRedeemFailure(token, 'preview', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
setShowWeakNet(true);
|
||||
} else if (report) {
|
||||
setFailCount(report.failCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPreview();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
async function confirm() {
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
}
|
||||
async function doConfirm() {
|
||||
if (!token.trim()) {
|
||||
setMsg('请先扫码获取核销码');
|
||||
return;
|
||||
@@ -103,6 +108,36 @@ export default function RedeemConfirmPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (storeClosed) {
|
||||
setShowOpenModal(true);
|
||||
return;
|
||||
}
|
||||
await doConfirm();
|
||||
}
|
||||
|
||||
async function openStoreAndContinue() {
|
||||
if (opening) return;
|
||||
setOpening(true);
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/store/status', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'OPEN' }),
|
||||
});
|
||||
setStoreClosed(false);
|
||||
setShowOpenModal(false);
|
||||
setMsg('');
|
||||
// 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
|
||||
await loadPreview();
|
||||
await doConfirm();
|
||||
} catch (e) {
|
||||
setShowOpenModal(false);
|
||||
setMsg(e instanceof Error ? e.message : '开启营业失败');
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}
|
||||
|
||||
const previewAmount = preview?.amount ?? 0;
|
||||
const userLabel = preview?.user?.nickname || preview?.user?.phone || '—';
|
||||
|
||||
@@ -117,7 +152,7 @@ export default function RedeemConfirmPage() {
|
||||
|
||||
<main className="shop-redeem-main">
|
||||
{storeClosed && (
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店当前未营业,无法核销</p>
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店目前休息中无法核销,开启营业后可继续</p>
|
||||
)}
|
||||
<section className="shop-redeem-card">
|
||||
<div className="shop-redeem-banner">
|
||||
@@ -189,13 +224,13 @@ export default function RedeemConfirmPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||
disabled={loading || storeClosed || !preview}
|
||||
disabled={loading || (!preview && !storeClosed)}
|
||||
onClick={() => void confirm()}
|
||||
>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{loading ? 'sync' : 'check_circle'}
|
||||
</span>
|
||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
|
||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : storeClosed ? '确认核销' : '加载中…'}</span>
|
||||
</button>
|
||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||
</>
|
||||
@@ -213,6 +248,33 @@ export default function RedeemConfirmPage() {
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showOpenModal && (
|
||||
<div className="shop-redeem-modal" role="dialog" aria-modal="true">
|
||||
<div className="shop-redeem-modal-card">
|
||||
<h4 className="shop-redeem-modal-title">门店休息中</h4>
|
||||
<p className="shop-redeem-modal-desc">门店目前休息中无法核销,是否开启营业?</p>
|
||||
<div className="shop-redeem-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-modal-cancel"
|
||||
disabled={opening}
|
||||
onClick={() => setShowOpenModal(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-modal-confirm"
|
||||
disabled={opening}
|
||||
onClick={() => void openStoreAndContinue()}
|
||||
>
|
||||
{opening ? '开启中…' : '确认开启'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
@@ -10,6 +11,14 @@ import {
|
||||
type ShopStoreOption,
|
||||
} from '../lib/api';
|
||||
|
||||
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat('/');
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
export default function SelectStorePage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession, store, authenticated } = useStoreSession();
|
||||
@@ -37,7 +46,7 @@ export default function SelectStorePage() {
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
if (storeId === currentStoreId) {
|
||||
navigate('/', { replace: true });
|
||||
goShopHome(navigate);
|
||||
return;
|
||||
}
|
||||
setLoadingId(storeId);
|
||||
@@ -45,7 +54,7 @@ export default function SelectStorePage() {
|
||||
try {
|
||||
const session = await selectStore(storeId);
|
||||
applySession(session);
|
||||
navigate('/', { replace: true });
|
||||
goShopHome(navigate);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||
} finally {
|
||||
@@ -166,9 +175,11 @@ export function routeAfterShopLogin(
|
||||
session: ShopSessionPayload,
|
||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||
) {
|
||||
if (needsStoreSelection(session)) {
|
||||
navigate('/select-store', { replace: true });
|
||||
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
||||
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat(path);
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
navigate(path, { replace: true });
|
||||
}
|
||||
|
||||
@@ -80,7 +80,6 @@ export default function WithdrawPage() {
|
||||
const canApply =
|
||||
!!summary?.isPrimary &&
|
||||
summary.availableAmount > 0 &&
|
||||
!summary.hasPendingRequest &&
|
||||
summary.hasBankAccount &&
|
||||
!submitting;
|
||||
|
||||
@@ -126,7 +125,6 @@ export default function WithdrawPage() {
|
||||
info
|
||||
</span>
|
||||
单日上限 ¥{formatMoney(summary?.dailyLimit ?? 5000)}
|
||||
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
|
||||
{!summary?.hasBankAccount ? ' · 请先完善收款账户' : ''}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -794,6 +794,7 @@
|
||||
|
||||
.shop-scan-auth-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -1518,6 +1519,7 @@
|
||||
z-index: 40;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-surface-container-highest);
|
||||
padding: 0 var(--space-page);
|
||||
}
|
||||
|
||||
.shop-records-range-tabs {
|
||||
@@ -1565,7 +1567,7 @@
|
||||
.shop-records-status-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
padding-bottom: 10px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
@@ -1896,8 +1898,8 @@
|
||||
}
|
||||
|
||||
.shop-withdraw-btn {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
width: calc(100% - 2 * var(--space-page));
|
||||
margin: 12px var(--space-page) 0;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
@@ -1915,11 +1917,80 @@
|
||||
|
||||
.shop-withdraw-msg {
|
||||
margin-top: 10px;
|
||||
padding: 0 var(--space-page);
|
||||
font-size: 13px;
|
||||
color: var(--color-aged-amber);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── 休息中核销·开张确认弹窗 ─── */
|
||||
.shop-redeem-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.shop-redeem-modal-card {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
padding: 24px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.shop-redeem-modal-title {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.shop-redeem-modal-desc {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
|
||||
.shop-redeem-modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shop-redeem-modal-cancel,
|
||||
.shop-redeem-modal-confirm {
|
||||
flex: 1;
|
||||
padding: 11px 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-redeem-modal-cancel {
|
||||
background: var(--color-surface-container, #ececec);
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
|
||||
.shop-redeem-modal-confirm {
|
||||
background: var(--color-primary, #8b1a1a);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.shop-redeem-modal-cancel:disabled,
|
||||
.shop-redeem-modal-confirm:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── 门店套餐 ─── */
|
||||
.shop-packages-page .shop-records-main {
|
||||
padding-bottom: 24px;
|
||||
|
||||
@@ -12,7 +12,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5174,
|
||||
proxy: { '/api': 'http://localhost:3000' },
|
||||
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { track } from '../lib/analytics';
|
||||
import { openWecomCustomerService } from '../lib/customer-service';
|
||||
import {
|
||||
getCustomerServicePhone,
|
||||
loadCustomerServicePhone,
|
||||
openWecomCustomerService,
|
||||
} from '../lib/customer-service';
|
||||
|
||||
type ContactCustomerSheetProps = {
|
||||
orderId?: string;
|
||||
@@ -9,7 +13,13 @@ type ContactCustomerSheetProps = {
|
||||
};
|
||||
|
||||
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
|
||||
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
const [phone, setPhone] = useState(getCustomerServicePhone);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCustomerServicePhone().then(setPhone);
|
||||
}, []);
|
||||
|
||||
const tel = phone.replace(/-/g, '');
|
||||
|
||||
function openPhone() {
|
||||
track('cs_contact', { type: 'phone', orderId });
|
||||
@@ -41,7 +51,7 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
|
||||
</div>
|
||||
<div className="contact-customer-option-body">
|
||||
<p className="contact-customer-option-title">拨打总部客服电话</p>
|
||||
<p className="contact-customer-option-sub">{CUSTOMER_SERVICE_PHONE}</p>
|
||||
<p className="contact-customer-option-sub">{phone}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reportApiError } from '@dukang/client-logging';
|
||||
|
||||
export const BRAND = {
|
||||
red: '#A02D30',
|
||||
yellow: '#FFC107',
|
||||
@@ -83,6 +85,12 @@ async function rawRequest<T>(
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code;
|
||||
if (json.code === 400) {
|
||||
reportApiError(
|
||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
import { isWechatEnv } from './weixin';
|
||||
|
||||
let cachedPhone = CUSTOMER_SERVICE_PHONE;
|
||||
|
||||
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
||||
export function getCustomerServiceWecomUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
||||
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
||||
}
|
||||
|
||||
export function getCustomerServicePhone(): string {
|
||||
return cachedPhone;
|
||||
}
|
||||
|
||||
/** 从系统设置拉取客服电话(失败则保持默认常量) */
|
||||
export async function loadCustomerServicePhone(): Promise<string> {
|
||||
try {
|
||||
const cfg = await fetchClientConfig();
|
||||
const phone = cfg.customerServicePhone?.trim();
|
||||
if (phone) cachedPhone = phone;
|
||||
} catch {
|
||||
/* keep fallback */
|
||||
}
|
||||
return cachedPhone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
||||
* @returns true 已跳转;false 非微信环境已提示
|
||||
@@ -20,4 +39,5 @@ export function openWecomCustomerService(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */
|
||||
export { CUSTOMER_SERVICE_PHONE };
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { apiBase } from './api';
|
||||
import {
|
||||
compressImageFileIfNeeded,
|
||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||
formatOssMaxSizeMb,
|
||||
} from '@dukang/shared-ui/compressImage';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
@@ -15,8 +20,15 @@ export async function uploadFileToOss(
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
let prepared = file;
|
||||
if (mediaType === 'IMAGE') {
|
||||
prepared = await compressImageFileIfNeeded(file);
|
||||
if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
||||
}
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', prepared);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service';
|
||||
import {
|
||||
getCustomerServicePhone,
|
||||
loadCustomerServicePhone,
|
||||
openWecomCustomerService,
|
||||
} from '../lib/customer-service';
|
||||
import { track } from '../lib/analytics';
|
||||
|
||||
export default function CustomerServicePage() {
|
||||
const navigate = useNavigate();
|
||||
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
const [phone, setPhone] = useState(getCustomerServicePhone);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCustomerServicePhone().then(setPhone);
|
||||
}, []);
|
||||
|
||||
const tel = phone.replace(/-/g, '');
|
||||
|
||||
function openOnline() {
|
||||
track('cs_contact', { type: 'wecom_kf' });
|
||||
@@ -27,7 +38,7 @@ export default function CustomerServicePage() {
|
||||
|
||||
<a className="customer-service-phone-link" href={`tel:${tel}`}>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
或拨打客服电话 {CUSTOMER_SERVICE_PHONE}
|
||||
或拨打客服电话 {phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -16,7 +16,7 @@ export default defineConfig(async () => ({
|
||||
plugins: ['@tarojs/plugin-html'],
|
||||
defineConstants: {
|
||||
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
|
||||
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3000'),
|
||||
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3010'),
|
||||
},
|
||||
copy: {
|
||||
patterns: [],
|
||||
@@ -44,7 +44,7 @@ export default defineConfig(async () => ({
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_TARGET ?? 'http://localhost:3000',
|
||||
target: process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ function resolveApiBase(): string {
|
||||
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
||||
? TARO_APP_API_ORIGIN
|
||||
: process.env.TARO_ENV === 'h5'
|
||||
? 'http://localhost:3000'
|
||||
? 'http://localhost:3010'
|
||||
: '';
|
||||
if (origin) {
|
||||
return `${origin.replace(/\/$/, '')}/api/v1`;
|
||||
|
||||
@@ -21,7 +21,7 @@ pnpm build:mini-user:weapp
|
||||
|
||||
| 环节 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3010`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||
|
||||
### 微信登录 `invalid code`
|
||||
@@ -33,7 +33,7 @@ pnpm build:mini-user:weapp
|
||||
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
||||
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
||||
|
||||
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock:
|
||||
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3010`),并开启 Mock:
|
||||
|
||||
```bash
|
||||
# 终端 1
|
||||
|
||||
@@ -11,7 +11,7 @@ const isDevMode =
|
||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||
const API_ORIGIN =
|
||||
process.env.VITE_API_TARGET ??
|
||||
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
|
||||
(isDevMode ? 'http://localhost:3010' : 'https://api.dukanghaoke.com');
|
||||
|
||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/mini-user",
|
||||
"version": "0.1.0",
|
||||
"version": "3.4.15",
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user