64 Commits

Author SHA1 Message Date
jacy 1f06530894 Merge pull request '小程序版本号' (#50) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/50
2026-08-26 07:15:52 +08:00
jacy 1f4506f60b 小程序版本号
CI / verify (pull_request) Waiting to run
2026-08-26 07:13:54 +08:00
jacy a2976cdc8f merge(dev_jacy): 同城运费路由与配送单信息 2026-08-25 21:12:53 +08:00
jacy cc0c0a6ef8 feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址
同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:12:36 +08:00
jacy ee1a3f4b6c merge(dev_jacy): 补承运商删除操作常量 2026-08-25 14:32:27 +08:00
jacy 52a7d3789d fix(ops): 补承运商删除操作常量以便生产构建
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:32:08 +08:00
jacy 98d52ff173 merge(dev_jacy): v3.5.10 同城配送提示与企微客服 2026-08-25 14:30:10 +08:00
jacy 797b20979d feat(mini-user): v3.5.10 同城配送提示可配置并支持换行
承运商 HTML 按收货市下发;textarea 回车在 C 端转成换行。含门店去掉分享按钮与小程序企微客服。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:29:48 +08:00
jacy b1511ecb92 merge(dev_jacy): 列设置靠右与订单状态多选 2026-08-25 14:20:13 +08:00
jacy fed8ff3d3a fix(admin): 列设置靠右并支持订单状态多选
HQ 列表主操作居右、列设置贴最右侧;订单筛选状态可多选,导出同步过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:19:49 +08:00
jacy 30f8d649cb Merge pull request 'v3.5.9版本迭代列表优化' (#48) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/48
2026-08-25 12:39:30 +08:00
jacy f4da71e952 v3.5.9版本迭代列表优化
CI / verify (pull_request) Waiting to run
2026-08-25 12:38:21 +08:00
jacy d7241d54b1 Merge pull request '给用户增加“备注”列' (#46) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/46
2026-08-25 10:10:07 +08:00
jacy 282da24bc4 给用户增加“备注”列
CI / verify (pull_request) Waiting to run
2026-08-25 10:09:05 +08:00
jacy 5e0691ab35 Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#44) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/44
2026-08-25 09:22:48 +08:00
jacy 5935024ea8 v3.5.8和v3.5.9版本更新
CI / verify (pull_request) Waiting to run
2026-08-25 09:20:32 +08:00
jacy 7304c7a8e1 chore(ops): 增加生产库备份/回滚与按 isTest 清理脚本
只入库通用运维能力,不提交写死手机号的一次性清理脚本与 dump。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 22:03:35 +08:00
jacy b90a4e9f8f Merge pull request 'v3.5.7版本迭代' (#42) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/42
2026-08-23 19:27:14 +08:00
jacy a3f17cbf94 v3.5.7版本迭代
CI / verify (pull_request) Has been cancelled
门店端UI修改
2026-08-23 19:26:12 +08:00
jacy 6973e3d7f5 Merge pull request '修改订单导出时间错误' (#40) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/40
2026-08-23 17:34:21 +08:00
jacy 114d77ca48 修改订单导出时间错误
CI / verify (pull_request) Has been cancelled
2026-08-23 17:33:20 +08:00
jacy a144a35c3d Merge pull request 'Dev jacy v3.5.6 版本迭代' (#38) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/38
2026-08-23 16:43:07 +08:00
jacy 7e12eb3ca6 v3.5.6版本迭代
CI / verify (pull_request) Has been cancelled
2026-08-23 16:41:22 +08:00
jacy 608e3e5ec5 整理发布脚本 2026-08-23 14:37:36 +08:00
jacy d98b0aefb0 整理上下文 2026-08-23 14:37:29 +08:00
jacy 012626f6bd Merge pull request 'v3.5.5版本上传' (#36) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/36
2026-08-23 12:29:33 +08:00
jacy 22cd00da42 v3.5.5版本上传
CI / verify (pull_request) Has been cancelled
2026-08-23 12:26:57 +08:00
jacy 3004a65b35 Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev 2026-08-21 23:52:37 +08:00
jacy f9161368e1 feat(admin): show HQ product list as SPU with expandable SKUs
Group catalog rows by product, add paid bottle sales, and keep multi-SKU fulfillment on SKU rows only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 23:52:28 +08:00
jacy 8a061f085f 提交v3.5.4 版本号小程序 2026-08-21 17:49:53 +08:00
jacy b20e566a65 logo3 2026-08-21 17:39:11 +08:00
jacy 7d2e4ef57e merge(dev_jacy): v3.5.4 规格主图与门店使用规则 2026-08-21 17:37:09 +08:00
jacy 4d434c9c67 v3.5.4 规格主图、门店使用规则与收银台 logo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 17:36:53 +08:00
jacy 73fd27bdf6 Merge pull request 'v3.5.4版本提交,修改api' (#34) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/34
2026-08-21 16:23:54 +08:00
jacy 62e61a9e63 v3.5.4版本提交,修改api
CI / verify (pull_request) Has been cancelled
2026-08-21 16:22:53 +08:00
jacy cd9696bc1c Merge pull request 'v3.5.4版本提交' (#32) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/32
2026-08-21 15:50:07 +08:00
jacy a01217539c v3.5.4版本提交
CI / verify (pull_request) Has been cancelled
2026-08-21 15:48:15 +08:00
jacy b5d4f8b27c Merge pull request 'v3.5.3版本更新2' (#30) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/30
2026-08-20 20:23:03 +08:00
jacy 26334ed072 v3.5.3版本更新2
CI / verify (pull_request) Has been cancelled
2026-08-20 20:09:05 +08:00
jacy 9a80f0f8d6 Merge pull request 'v3.5.3版本更新1' (#29) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/29
2026-08-20 18:55:43 +08:00
jacy fc2e5b65de v3.5.3版本更新1
CI / verify (pull_request) Has been cancelled
2026-08-20 18:54:15 +08:00
jacy e8b585e623 Merge pull request '增加音效逻辑' (#26) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/26
2026-08-20 15:06:07 +08:00
jacy ee493823bf 增加音效逻辑
CI / verify (pull_request) Has been cancelled
2026-08-20 15:04:54 +08:00
jacy 5702266276 Merge pull request 'Dev jacy' (#25) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/25
2026-08-20 13:43:29 +08:00
jacy 8b5bb6a981 大屏显示优化
CI / verify (pull_request) Has been cancelled
2026-08-20 13:41:50 +08:00
jacy 0a36188b35 调整优化大屏 2026-08-20 12:55:11 +08:00
jacy a8a37fbc4a Merge pull request 'v3.5.2 大屏调整' (#22) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/22
2026-08-20 12:00:57 +08:00
jacy 6cf66c722c v3.5.2 大屏调整
CI / verify (pull_request) Has been cancelled
2026-08-20 11:58:15 +08:00
jacy e34416a9b3 Merge pull request 'Dev jacy mock 支付调整' (#20) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/20
2026-08-20 00:54:17 +08:00
jacy 4db21cf1a1 mockpay功能调试
CI / verify (pull_request) Has been cancelled
2026-08-20 00:51:18 +08:00
jacy 2f633b307c 删除h5user端和hq小程序端 2026-08-19 16:21:06 +08:00
jacy 62353a6800 整理开发文档 2026-08-19 16:20:33 +08:00
jacy 6c44e9c56f Merge pull request 'v3.5.1 版本更新' (#18) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/18
2026-08-19 16:00:56 +08:00
jacy 233ed0af3b v3.5.1 版本更新
CI / verify (pull_request) Has been cancelled
2026-08-19 15:54:51 +08:00
jacy 905e145af3 Merge pull request 'v3.4.18' (#16) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/16
2026-08-17 23:45:00 +08:00
jacy 7dd5fdfb12 优化门店端结算页面的UI
CI / verify (pull_request) Has been cancelled
2026-08-17 23:43:45 +08:00
jacy 48d6900ab6 fix(iam): 合伙人登录双拦截——子账号status=DISABLED 与 主账号bindingStatus=PAUSED 均拒绝登录
CI / verify (push) Has been cancelled
2026-08-17 23:39:53 +08:00
jacy 5ca9aa00aa fix(h5-partner): 微信OAuth回跳错误不再吞没,DISABLED提示可见
CI / verify (push) Has been cancelled
- 合伙人端 PartnerSessionContext 微信回跳 catch 原把后端
  「该账号已暂停使用,请联系客服人员」错误整体吞掉,导致
  DISABLED 账号微信登录无任何反馈
- 改为用 toastError 显式提示,与短信路径一致(微信同理)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-17 23:22:04 +08:00
jacy c0d04ee500 fix(h5-shop): 门店 PAUSED 时点击扫码即弹窗,前置到首页
CI / verify (push) Has been cancelled
- 门店临时闭店/休息中(非 OPEN)点击首页「扫码核销」立即弹
  「门店目前休息中无法核销,是否开启营业」,不进入扫码流程
- 开张成功后刷新门店状态,可再次扫码
- 修正此前“扫码后才提示”的方案偏差;文档 E 节同步更新

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-17 23:03:55 +08:00
jacy 3ac008a217 fix(h5-shop): 修复闭店/休息中扫码核销弹窗不触发
CI / verify (push) Has been cancelled
- 根因:门店非OPEN时后端 preview 拦截,preview 为 null 导致确认按钮 disabled,弹窗永远触发不了
- RedeemConfirmPage:storeClosed 时确认按钮可点→弹开张框;开张后重拉预览并继续核销
- 弹窗文案对齐「门店目前休息中无法核销,是否开启营业」

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-17 22:50:22 +08:00
jacy cd1e9ae488 refactor(mini-user): 门店套餐图改用 swiper auto-height 自适应(方案A)
CI / verify (push) Has been cancelled
- 移除手算高度逻辑,自适应模式启用 swiper 原生 autoHeight
- image 保持 mode=widthFix,完整展示不裁切
- 删除 .detail-carousel-wrap 固定比例/高度的覆盖依赖

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-17 22:20:05 +08:00
jacy 18147e157f fix(mini-user): 门店套餐图片自适应完整显示,不再裁剪
CI / verify (push) Has been cancelled
- ProductCarousel 新增 imageFit="adaptive"(widthFix + 动态高度)
- 门店套餐详情按图片真实比例自适应高度,完整显示不裁剪
- 补充 v3.4.18 文档 F 项

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-17 21:48:06 +08:00
jacy f7c94b4f16 Merge commit '984dec79ee1944e2c21ed2e564cac09ae893afa3' into dev_jacy 2026-08-17 21:40:44 +08:00
jacy cc750dc1d6 feat: v3.4.18 门店体验与登录态优化
- mini-user 门店座机电话中间四位脱敏(保留区号与前后位)
- mini-user 套餐详情图增加 1/N 计数与自动轮播
- h5-partner 账号暂停(DISABLED)禁止登录并提示客服
- h5-shop 提现/筛选栏左右留白与页面一致
- h5-shop 休息中核销改为弹「是否开启营业」并支持开张后继续

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-17 21:34:40 +08:00
499 changed files with 24770 additions and 22406 deletions
-55
View File
@@ -1,55 +0,0 @@
---
description: 杜康好客全局约束 — Monorepo 布局、文档优先级、2 人 OWNER 边界
alwaysApply: true
---
# 杜康好客 · 核心规则
## Monorepo 布局
- `apps/` — preV1`h5-user`、`h5-shop`、`h5-partner`、`admin-web`
- `packages/` — `shared-types`、`domain`、`shared-ui`
- `server/dukang-api/` — NestJS 单体 API
- `pages/` — UI 原型(**只读参照**
> V2 目标为 Taro 四端(`mini-user` 等),编码以 **当前实际目录** 为准。
## 文档
- **V3.0 业务事实源**`杜康好客-v3-PRD.md`
- V3 实现验收:`杜康好客-v3编码手册.md`
- V2 蓝图:`杜康好客-V2编码手册.md`(与 V3 冲突时 V3 PRD 优先)
- preV1 裁剪:`杜康好客-preV1编码手册.md`
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
**禁止**:编码时修改业务规则;需求变更只改 `杜康好客-v3-PRD.md`。
## 边界(2 人团队)
> **临时(2026-07**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
| 负责人 | 路径 |
|--------|------|
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
| ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
- `apps/*` 禁止 import `server/*` 或其他 app
- Mock 只在 `integrations/*`,不在业务 Service 散落
## 共享契约
- 枚举/DTO → `packages/shared-types`
- 纯规则 → `packages/domain`(无 IO
## 核心业务常量
- 权益额 = `benefit_amount ?? price`
- 核销:直接核销 `0 < amount ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额`Redis 码 3 分钟
- 订单 Tab(V3.0):`待付款 | 已付款 | 已完成`
- API`/api/v1`,响应 `{ code, message, data }`
## 提交
- Conventional Commits`feat(trade):` 等
- 不提交 `.env`
+6 -28
View File
@@ -1,33 +1,11 @@
--- ---
description: Git 提交与 PR 协作规范 description: 杜康提交 scope 与发版默认(生产)
alwaysApply: true alwaysApply: true
--- ---
# Git 规范 # Git / 发版(杜康专属)
## 提交格式 - Conventional Commitsscope = 端或模块:`feat(trade):`、`fix(redeem):`
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
Conventional Commitsscope = 端或模块名: - 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- Prisma 迁移须 Review
```
feat(trade): add order preview API
fix(redeem): validate redeem amount against balance
chore(shared-types): add OrderStatus enum
```
## 禁止提交
- `.env`、`.env.local` 等敏感配置
- `node_modules/`、`dist/`(已在 .gitignore
## PR 与 Review
- feature 分支 + 小步 PR
- 跨模块改动需相关双 Owner Review
- Prisma 迁移必须多 Owner Review
## Agent 行为
- **仅用户明确要求时** 才执行 git commit / push
- 不 amend 已推送的 commit,不 force push main/master/dev
- 发版:`dev`→测试(staging)`main`→生产;用户只说「发布/发版」默认发**生产**(见 dukang-release skill);明确说「发测试」才发 staging
@@ -0,0 +1,34 @@
---
description: 小程序子页不要画与原生导航栏重复的二级 title,标题只用 navigationBarTitleText
globs: apps/mini-user/**/*.{tsx,ts}
alwaysApply: false
---
# mini-user · 小程序导航标题
微信小程序已有原生导航栏。页面内再画一层 `SubPageHeader` / 自定义 title,会与原生标题叠成**二级 title**。
## weapp
- 页面标题只走 `index.config.ts` 的 `navigationBarTitleText`
- **不要**设 `navigationStyle: 'custom'`(除非该页需要完全自定义导航,如滚动透明顶栏、商品详情)
- **不要**在页面内再渲染与原生标题重复的 `SubPageHeader`
- H5 没有原生导航栏:可保留 `SubPageHeader`(仅返回键;H5 实现已隐藏 title 文案)
```tsx
// ✅ weapp 用原生标题;H5 保留返回栏
export default definePageConfig({
navigationBarTitleText: '申请发票',
});
{process.env.TARO_ENV === 'h5' ? (
<SubPageHeader title="申请发票" onBack={...} />
) : null}
```
```tsx
// ❌ 未配页标题 + 再画一层 SubPageHeaderweapp 会双标题)
<SubPageHeader title="申请发票" />
```
弹层/区块标题(如「编辑发票抬头」)不是导航二级 title,可保留。
+1 -1
View File
@@ -21,7 +21,7 @@ disable-model-invocation: true
## 会话启动(按序) ## 会话启动(按序)
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`preV1 → preV1 手册;V2 蓝图 → V2 手册 1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`preV1 → preV1 手册;V2 蓝图 → V2 手册
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER **只改本 OWNER 路径** 2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 段 + 对应 `apps/``server/dukang-api/AGENTS.md`**只改本 OWNER 路径**
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七 3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型 4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
5. 编码前过一遍下方 Checklist 5. 编码前过一遍下方 Checklist
+1
View File
@@ -5,6 +5,7 @@ description: >-
MOCK_SMS/MOCK_PAY/MOCK_DELIVERY flags, Seed data, and V2 upgrade path. MOCK_SMS/MOCK_PAY/MOCK_DELIVERY flags, Seed data, and V2 upgrade path.
Use when working on Mock providers, feature flags, preV1 task cards P1-*, Use when working on Mock providers, feature flags, preV1 task cards P1-*,
or smoke tests — not for V2 WeChat/payment production integration. or smoke tests — not for V2 WeChat/payment production integration.
disable-model-invocation: true
--- ---
# 杜康好客 · preV1 Mock Skill # 杜康好客 · preV1 Mock Skill
+1
View File
@@ -4,6 +4,7 @@ description: >-
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。 杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产, Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
or @杜康发布 / @dukang-release. or @杜康发布 / @dukang-release.
disable-model-invocation: true
--- ---
# 杜康发布 # 杜康发布
+1
View File
@@ -4,6 +4,7 @@ description: >-
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003, verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003,
or asks to start/implement a milestone card from the coding manuals. or asks to start/implement a milestone card from the coding manuals.
disable-model-invocation: true
--- ---
# 杜康好客 · 任务卡 Skill # 杜康好客 · 任务卡 Skill
+5
View File
@@ -21,4 +21,9 @@ coverage/
server/dukang-api/prisma/migrations/ server/dukang-api/prisma/migrations/
debug_v3.xlsx debug_v3.xlsx
~$debug_v3.xlsx ~$debug_v3.xlsx
server/dukang-api/assets/fonts/*.ttf
server/dukang-api/assets/fonts/*.ttc
server/dukang-api/assets/fonts/*.otf
deploy/auto-release.env deploy/auto-release.env
deploy/backups/
.playwright-mcp/
+70
View File
@@ -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 数量=0binding_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矛盾,疑似另一轮实验性改动。已与用户确认前**暂不提交**。
+26 -149
View File
@@ -1,133 +1,37 @@
# 杜康好客 · AGENTS.md # 杜康好客 · AGENTS.md
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载 > AI 编码入口。人类读 [`agent.md`](./agent.md)。启动、端口、测试账号见 [`README.md`](./README.md)
## 项目概览 杜康酒业 O2O:**购酒 → 发券 → 门店核销**。Monorepo + NestJS 单体(`/api/v1``{ code, message, data }`)。
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。 | 路径 | 说明 |
|------|------|
| `apps/` | `h5-user` · `h5-shop` · `h5-partner` · `admin-web` · `mini-user` |
| `packages/` | `shared-types`(枚举/DTO)· `domain`(纯规则,无 IO)· `shared-ui` |
| `server/dukang-api/` | 唯一 API |
| `pages/` | UI 原型,只读 |
| 阶段 | 手册 | 说明 | 前端:[`apps/AGENTS.md`](./apps/AGENTS.md)。后端模块/表 OWNER[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)。
|------|------|------|
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考(**已压缩**V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。 ## 文档(冲突时)
## 启动命令 1. `杜康好客-v3-PRD.md`**唯一需求事实源**(编码时禁止改业务规则;需求变更只改它)
2. `杜康好客-v3-现状对照.md` — 进度
```bash 3. `杜康好客-v3编码手册.md` — 验收、核销、分工
# 基础设施 4. `conventions.md` — 协作边界
cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017 5. V2 手册 §四–§六 — 架构/DB/API **参考**(与 V3 冲突则忽略)
# 依赖与数据库
pnpm install
cp server/dukang-api/.env.example server/dukang-api/.env
pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端)
pnpm dev:api # http://localhost:3010/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
# 验证
node scripts/smoke-v3.mjs
node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
## 文档优先级(冲突时)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**
2. `杜康好客-v3-现状对照.md` — 实现进度审计
3. `杜康好客-v3编码手册.md` — 实现验收、核销、分工
4. `conventions.md` — 协作与模块边界
5. V2 手册 §四 §五 §六 — 仅作架构/DB/API **参考**(与 V3 冲突时忽略)
6. `pages/{user,shop,partner,hq}/` — UI 参照 6. `pages/{user,shop,partner,hq}/` — UI 参照
> **V2 / preV1 手册不再作需求依据。** **禁止**:臆造 PRD 未定义规则;依赖 `doc/` 过时文档;`apps/*` import `server/*`;跨模块 `prisma` 写他人表。V2 / preV1 手册不再作需求依据。
## 团队与 OWNER 边界(2 人 ## OWNER2026-07 起暂由 jacy 代管全部
| 负责人 | Git 账号 | 职责 | `jacy-dukang` 可改 `apps/*``packages/*`、全部后端模块。逻辑边界仍按 A/B/C/D,便于子 Agent 隔离。Prisma 迁移由 jacy 主导。
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管** |
> **临时分工(2026-07**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。 - 跨模块只 inject 对方 **exported Service**
- Mock 只在 `integrations/*`;微信/配送回调只进 `callbacks/`
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下: ## 不可偏离
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|------------|--------|----------|-------------|
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
**Prisma 迁移**jacy-dukang 主导 Review;涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review)。
### 跨模块规则(R1–R8 摘要)
- 只 inject 对方 Module **exports 的 Service**,禁止 `prisma.xxx` 写他人表
- `apps/*` 禁止 import `server/*` 源码;只走 HTTP + `packages/shared-types`
- 业务纯规则 → `packages/domain`;枚举/DTO → `packages/shared-types`
- 微信/配送回调入口 **仅** `callbacks/`Mock 实现 **仅** `integrations/*`
## Cursor 规范
| 类型 | 路径 | 说明 |
|------|------|------|
| Rules | `.cursor/rules/` | 自动加载的编码规范 |
| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
| V3.0 需求 Skill | `.cursor/skills/dukang-v3/` | @dukang-v3PRD、Wave、REQ/ACC |
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用)
在 Cursor Agent 输入 `/` 选择:
| 子 Agent | 负责人 | 适用场景 |
|----------|--------|----------|
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
## Skills(按需 @
| Skill | 何时用 |
|-------|--------|
| `dukang-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
- PR 前审查:`/boundary-reviewer`
- 原型参照:`pages/{user,shop,partner}/`(只读)
## 核心业务常量(不可偏离)
``` ```
权益额 = benefit_amount ?? price 权益额 = benefit_amount ?? price
@@ -137,37 +41,10 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成 订单 Tab:待付款 | 已付款 | 已完成
``` ```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」 微信:iOS H5 登录后禁止仅 SPA 跳转再扫码`packages/weixin-sdk/GOTCHAS.md`)。小程序 `open-type` 按钮祖先禁止 `stopPropagation`weapp 标题只用 `navigationBarTitleText``h5-partner``admin-web` 同端口 5175,勿同时开
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
## 环境与发版 ## 按需加载
| 环境 | 分支 | 目录 | 端口 | 域名 | - 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产)
|------|------|------|------|------| - 子 Agent`/owner-a-user-trade` 等(见 `.cursor/agents/`
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — | - DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev`
- 生产发布 → `deploy/deploy-prod.sh``main`,验收后)
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example`
- 详情:`@dukang-release` / `.cursor/skills/dukang-release/SKILL.md`
## 提交与 PR
- Conventional Commits`feat(trade):``fix(redeem):`scope = 端或模块
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- 不提交 `.env``dist/``node_modules/`
## 完成定义(DoD
- [ ] 任务卡验收项全部满足
- [ ] 未跨模块直写 Prisma 表
- [ ] 枚举/DTO 在 `shared-types`;纯规则在 `domain`
- [ ] `pnpm lint` 无新增错误;相关 domain 单测通过
## 嵌套指引
- 后端细节:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)
- 前端四 App[`apps/AGENTS.md`](./apps/AGENTS.md)
+9
View File
@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage'; import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage'; import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage'; import OrdersPage from './pages/OrdersPage';
import BigScreenPage from './pages/BigScreenPage';
import StorePackageAuditsPage from './pages/StorePackageAuditsPage'; import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
import StoresPage from './pages/StoresPage'; import StoresPage from './pages/StoresPage';
import StoreRatingsPage from './pages/StoreRatingsPage'; import StoreRatingsPage from './pages/StoreRatingsPage';
@@ -65,6 +66,14 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route
path="/orders/big-screen"
element={
<RequireAuth>
<BigScreenPage />
</RequireAuth>
}
/>
<Route <Route
element={ element={
<RequireAuth> <RequireAuth>
+739 -16
View File
@@ -16,37 +16,109 @@ body,
} }
/* /*
* 后台内容行:不换行 + 超出省略 * 后台表格:不换行、完整展示(v3.5.9 去掉省略号)
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表 * 描述列表仍省略,避免详情抽屉被长字段撑破
*/ */
.admin-layout .ant-table-cell, .admin-layout .ant-table-cell,
.ant-drawer .ant-table-cell, .ant-drawer .ant-table-cell,
.ant-modal .ant-table-cell, .ant-modal .ant-table-cell {
white-space: nowrap;
overflow: visible;
text-overflow: clip;
max-width: none;
}
.admin-layout .ant-descriptions-item-content, .admin-layout .ant-descriptions-item-content,
.ant-drawer .ant-descriptions-item-content, .ant-drawer .ant-descriptions-item-content,
.ant-modal .ant-descriptions-item-content { .ant-modal .ant-descriptions-item-content {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 0;
}
.admin-layout .ant-descriptions-item-content,
.ant-drawer .ant-descriptions-item-content,
.ant-modal .ant-descriptions-item-content {
max-width: 100%; max-width: 100%;
} }
/* 表头同样不换行 */ /* 表头不换行 */
.admin-layout .ant-table-thead > tr > th, .admin-layout .ant-table-thead > tr > th,
.ant-drawer .ant-table-thead > tr > th, .ant-drawer .ant-table-thead > tr > th,
.ant-modal .ant-table-thead > tr > th { .ant-modal .ant-table-thead > tr > th {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: visible;
text-overflow: ellipsis; text-overflow: clip;
} }
/* 操作列、含交互控件的不截断 */ /* 拖表头分割线改列宽 */
.admin-layout .ant-table-thead > tr > th.admin-th-resizable {
position: relative;
}
.admin-col-resize-handle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 10px;
cursor: col-resize;
z-index: 3;
user-select: none;
touch-action: none;
}
.admin-col-resize-handle:hover,
.admin-col-resizing .admin-col-resize-handle {
background: rgba(22, 119, 255, 0.18);
}
body.admin-col-resizing,
body.admin-col-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
/* 列表页顶栏:标题左、主操作右、列设置最右 */
.admin-list-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.admin-list-header-left {
min-width: 0;
}
.admin-list-header-desc {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.admin-list-header-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.admin-list-settings-slot {
display: inline-flex;
margin-left: auto;
order: 99;
flex-shrink: 0;
}
.admin-list-settings-btn.ant-btn {
color: rgba(0, 0, 0, 0.55);
padding-inline: 8px;
}
.admin-list-settings-btn.ant-btn:hover {
color: rgba(0, 0, 0, 0.88);
}
/* 操作列保持可见 */
.admin-layout .ant-table-cell:has(.ant-btn), .admin-layout .ant-table-cell:has(.ant-btn),
.ant-drawer .ant-table-cell:has(.ant-btn), .ant-drawer .ant-table-cell:has(.ant-btn),
.ant-modal .ant-table-cell:has(.ant-btn), .ant-modal .ant-table-cell:has(.ant-btn),
@@ -71,10 +143,9 @@ body,
max-width: none; max-width: none;
} }
/* 表格单元格双行信息合并为单行省略 */ /* 表格单元格主/副信息同一行 */
.admin-cell-line { .admin-cell-line {
display: block; display: inline;
max-width: 100%;
} }
.admin-cell-line-secondary { .admin-cell-line-secondary {
@@ -82,6 +153,23 @@ body,
font-size: 12px; font-size: 12px;
} }
.admin-primary-link {
display: inline;
margin: 0;
padding: 0;
border: 0;
background: none;
font: inherit;
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.admin-primary-link:hover {
color: #1677ff;
}
.admin-table-nowrap .ant-table-cell, .admin-table-nowrap .ant-table-cell,
.admin-table-nowrap .ant-table-cell-ellipsis { .admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap; white-space: nowrap;
@@ -104,3 +192,638 @@ body,
overflow: visible; overflow: visible;
max-width: none; max-width: none;
} }
/* v3.5.1 #1 订单大屏:发布会现场 */
html:has(.big-screen-page),
body:has(.big-screen-page),
#root:has(.big-screen-page) {
overflow: hidden;
height: 100%;
}
.big-screen-page {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: radial-gradient(ellipse at 50% 18%, #163a6b 0%, #0b1e3a 42%, #061224 100%);
color: #d6e8ff;
padding: 20px 48px 24px;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
.big-screen-stars {
pointer-events: none;
position: absolute;
inset: 0;
background-image:
radial-gradient(1px 1px at 8% 18%, rgba(255, 255, 255, 0.35), transparent),
radial-gradient(1px 1px at 22% 72%, rgba(160, 210, 255, 0.28), transparent),
radial-gradient(1.5px 1.5px at 78% 24%, rgba(255, 255, 255, 0.22), transparent),
radial-gradient(1px 1px at 91% 68%, rgba(160, 210, 255, 0.3), transparent),
radial-gradient(1px 1px at 46% 88%, rgba(255, 255, 255, 0.18), transparent),
radial-gradient(1.5px 1.5px at 61% 12%, rgba(160, 210, 255, 0.25), transparent);
opacity: 0.7;
}
.big-screen-frame {
pointer-events: none;
position: absolute;
inset: 14px 18px;
}
.big-screen-corner {
position: absolute;
width: 28px;
height: 28px;
border: 1px solid rgba(105, 192, 255, 0.7);
}
.big-screen-corner--tl {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
.big-screen-corner--tr {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
}
.big-screen-corner--bl {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
}
.big-screen-corner--br {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
.big-screen-header {
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid rgba(105, 192, 255, 0.18);
}
.big-screen-brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.big-screen-title {
margin: 0;
font-size: 28px;
line-height: 1;
font-weight: 700;
letter-spacing: 4px;
color: #fff;
}
.big-screen-subtitle {
font-size: 16px;
color: rgba(200, 220, 245, 0.55);
letter-spacing: 2px;
}
.big-screen-live-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.big-screen-live-label {
font-size: 18px;
color: rgba(230, 244, 255, 0.88);
letter-spacing: 2px;
}
.big-screen-live {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 10px;
border: 1px solid rgba(64, 169, 255, 0.7);
border-radius: 6px;
font-size: 14px;
font-weight: 700;
color: #fff;
letter-spacing: 2px;
background: rgba(8, 28, 56, 0.55);
}
.big-screen-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff4d4f;
box-shadow: 0 0 8px #ff4d4f;
animation: big-screen-pulse 1.2s ease-in-out infinite;
}
@keyframes big-screen-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.75);
}
}
.big-screen-hero {
position: relative;
z-index: 1;
flex: 0 0 auto;
text-align: center;
padding: 16px 0 12px;
}
.big-screen-clock {
font-size: 84px;
line-height: 1;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 10px;
color: #9fd3ff;
text-shadow: 0 0 28px rgba(105, 192, 255, 0.45);
}
.big-screen-date {
margin-top: 8px;
font-size: 16px;
letter-spacing: 4px;
color: rgba(180, 210, 240, 0.65);
}
.big-screen-list {
position: relative;
z-index: 1;
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.big-screen-list-head,
.big-screen-row {
display: grid;
grid-template-columns: minmax(180px, 18%) minmax(0, 1fr) minmax(140px, 16%) minmax(160px, 18%);
align-items: center;
column-gap: 16px;
width: 100%;
box-sizing: border-box;
}
.big-screen-list-body {
flex: 1 1 0%;
min-height: 0;
height: 0;
overflow: hidden;
position: relative;
}
.big-screen-track {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
flex-direction: column;
}
.big-screen-track.is-rolling {
animation: big-screen-marquee-up var(--marquee-ms, 20s) linear infinite;
}
.big-screen-track.is-paused {
animation-play-state: paused;
}
@keyframes big-screen-marquee-up {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
.big-screen-row {
position: relative;
flex: 0 0 auto;
height: 68px;
margin-bottom: 12px;
padding: 0 28px 0 40px;
background: rgba(18, 48, 88, 0.45);
border: 1px solid transparent;
border-radius: 4px;
font-size: 22px;
color: #e8f4ff;
box-sizing: border-box;
}
.big-screen-row.is-latest {
border-color: rgba(64, 169, 255, 0.85);
box-shadow: 0 0 16px rgba(24, 144, 255, 0.28);
}
.big-screen-row--t1 {
background: rgba(22, 54, 98, 0.48);
border-color: rgba(105, 192, 255, 0.16);
}
.big-screen-row--t1 .big-screen-amount {
color: #e6f4ff;
}
.big-screen-row--t2.is-latest {
border-color: rgba(255, 229, 143, 0.95);
box-shadow: 0 0 18px rgba(255, 229, 143, 0.42);
}
.big-screen-row--t3.is-latest {
border-color: #ffd666;
box-shadow: 0 0 22px rgba(250, 173, 20, 0.55);
}
.big-screen-row--t2 {
background: rgba(64, 48, 8, 0.42);
border-color: rgba(255, 229, 143, 0.45);
color: #fff7d6;
}
.big-screen-row--t2 .big-screen-amount {
color: #ffe58f;
text-shadow: 0 0 10px rgba(255, 229, 143, 0.45);
}
.big-screen-row--t3 {
background: rgba(72, 48, 0, 0.5);
border-color: rgba(250, 173, 20, 0.75);
color: #ffe7a3;
box-shadow: 0 0 18px rgba(250, 173, 20, 0.28);
}
.big-screen-row--t3 .big-screen-amount {
color: #ffd666;
text-shadow: 0 0 14px rgba(255, 214, 102, 0.7);
}
.big-screen-row-mark {
position: absolute;
left: 12px;
top: 50%;
width: 10px;
height: 22px;
margin-top: -11px;
border-radius: 6px;
background: #40a9ff;
box-shadow: 0 0 10px #40a9ff;
}
.big-screen-row--t2 .big-screen-row-mark {
background: #ffe58f;
box-shadow: 0 0 10px #ffe58f;
}
.big-screen-row--t3 .big-screen-row-mark {
background: #ffd666;
box-shadow: 0 0 12px #ffd666;
}
.big-screen-amount {
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
letter-spacing: 0.5px;
min-width: 0;
}
.big-screen-amount--6d {
font-size: 0.86em;
letter-spacing: 0;
}
.big-screen-items {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.big-screen-time,
.big-screen-phone {
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
white-space: nowrap;
}
.big-screen-empty {
position: absolute;
left: 0;
right: 0;
/* 全屏高度黄金分割点(≈0.382),略偏上 */
top: 38.2%;
transform: translateY(-50%);
z-index: 2;
text-align: center;
color: rgba(145, 190, 230, 0.42);
padding: 0 40px;
font-size: 60px;
letter-spacing: 6px;
font-weight: 400;
pointer-events: none;
}
.big-screen-fx {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
overflow: hidden;
background: rgba(4, 12, 28, 0.38);
}
.big-screen-fx-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.big-screen-fx-shock {
position: absolute;
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid rgba(255, 214, 102, 0.85);
animation: big-screen-shock 1.1s ease-out forwards;
}
@keyframes big-screen-shock {
0% {
transform: scale(0.2);
opacity: 1;
}
100% {
transform: scale(28);
opacity: 0;
}
}
.big-screen-fx-sweep {
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 38%,
rgba(255, 245, 200, 0.22) 50%,
transparent 62%
);
background-size: 220% 100%;
animation: big-screen-sweep 1.4s ease-out 0.15s both;
}
.big-screen-fx-sweep--alt {
animation-delay: 0.55s;
background: linear-gradient(
65deg,
transparent 38%,
rgba(255, 214, 102, 0.2) 50%,
transparent 62%
);
}
.big-screen-fx-shock--late {
animation-delay: 0.35s;
border-color: rgba(255, 236, 179, 0.55);
}
@keyframes big-screen-sweep {
from {
background-position: 120% 0;
opacity: 0;
}
30% {
opacity: 1;
}
to {
background-position: -40% 0;
opacity: 0;
}
}
.big-screen-fx-card {
position: relative;
z-index: 2;
min-width: min(420px, 92vw);
max-width: min(920px, 92vw);
padding: 28px 40px 32px;
border-radius: 12px;
text-align: center;
background: rgba(8, 22, 48, 0.82);
backdrop-filter: blur(8px);
box-sizing: border-box;
}
.big-screen-fx-card--t1 {
border: 2px solid rgba(105, 192, 255, 0.85);
box-shadow: 0 0 32px rgba(24, 144, 255, 0.45);
animation: big-screen-card-in-t1 0.55s cubic-bezier(0.2, 0.9, 0.2, 1) both;
}
.big-screen-fx-card--t2 {
border: 2px solid rgba(255, 229, 143, 0.95);
box-shadow: 0 0 40px rgba(255, 214, 102, 0.5);
animation: big-screen-card-in-t2 0.6s cubic-bezier(0.16, 1.2, 0.3, 1) both;
}
.big-screen-fx-card--t3 {
min-width: min(560px, 94vw);
padding: 36px 48px 40px;
border: 3px solid #ffd666;
box-shadow:
0 0 28px rgba(255, 214, 102, 0.85),
0 0 80px rgba(250, 173, 20, 0.45);
animation: big-screen-card-in-t3 0.7s cubic-bezier(0.12, 1.4, 0.2, 1) both;
}
@keyframes big-screen-card-in-t1 {
from {
opacity: 0;
transform: translateY(80px) scale(0.92);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes big-screen-card-in-t2 {
0% {
opacity: 0;
transform: scale(0.6);
}
70% {
transform: scale(1.06);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes big-screen-card-in-t3 {
0% {
opacity: 0;
transform: scale(0.4) rotate(-4deg);
}
55% {
transform: scale(1.12) rotate(1deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(0);
}
}
.big-screen-fx-kicker {
font-size: 16px;
letter-spacing: 8px;
color: rgba(230, 244, 255, 0.7);
margin-bottom: 8px;
}
.big-screen-fx-card--t2 .big-screen-fx-kicker,
.big-screen-fx-card--t3 .big-screen-fx-kicker {
color: #ffe58f;
}
.big-screen-fx-amount {
font-size: clamp(44px, 8.5vw, 64px);
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
line-height: 1.1;
color: #e6f7ff;
max-width: 100%;
margin-inline: auto;
}
.big-screen-fx-amount--6d {
font-size: clamp(36px, 7vw, 52px) !important;
letter-spacing: 1px;
}
.big-screen-fx-card--t1 .big-screen-fx-amount {
color: #91d5ff;
text-shadow: 0 0 18px rgba(105, 192, 255, 0.6);
}
.big-screen-fx-card--t2 .big-screen-fx-amount {
color: #ffe58f;
text-shadow: 0 0 20px rgba(255, 229, 143, 0.7);
animation: big-screen-amount-pop 0.8s ease-out 0.15s both;
}
.big-screen-fx-card--t3 .big-screen-fx-amount {
font-size: clamp(52px, 10vw, 84px);
color: #ffd666;
text-shadow:
0 0 12px #ffd666,
0 0 36px rgba(250, 173, 20, 0.8);
animation: big-screen-amount-flash 0.9s ease-in-out infinite;
}
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
font-size: clamp(44px, 8.5vw, 68px) !important;
letter-spacing: 1px;
}
@keyframes big-screen-amount-pop {
0% {
transform: scale(0.7);
}
70% {
transform: scale(1.12);
}
100% {
transform: scale(1);
}
}
@keyframes big-screen-amount-flash {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(1.35);
}
}
.big-screen-fx-items {
margin-top: 12px;
font-size: 22px;
color: #fff;
}
.big-screen-fx-meta {
margin-top: 10px;
display: flex;
justify-content: center;
gap: 28px;
font-size: 16px;
color: rgba(210, 228, 250, 0.75);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1200px) {
.big-screen-clock {
font-size: 56px;
}
.big-screen-row {
grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
font-size: 16px;
}
.big-screen-amount--6d {
font-size: 0.82em;
}
.big-screen-fx-amount {
font-size: clamp(36px, 8vw, 44px);
}
.big-screen-fx-card--t3 .big-screen-fx-amount {
font-size: clamp(40px, 9vw, 56px);
}
.big-screen-fx-amount--6d {
font-size: clamp(32px, 6.5vw, 40px) !important;
}
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
font-size: clamp(34px, 7vw, 48px) !important;
}
}
@@ -1,12 +1,10 @@
import { Typography } from 'antd';
type AdminCellLineProps = { type AdminCellLineProps = {
primary?: string | null; primary?: string | null;
secondary?: string | null; secondary?: string | null;
separator?: string; separator?: string;
}; };
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */ /** 表格单元格:主/副信息同一行完整展示 */
export function AdminCellLine({ export function AdminCellLine({
primary, primary,
secondary, secondary,
@@ -14,14 +12,9 @@ export function AdminCellLine({
}: AdminCellLineProps) { }: AdminCellLineProps) {
const main = primary?.trim() || '—'; const main = primary?.trim() || '—';
const sub = secondary?.trim(); const sub = secondary?.trim();
const full = sub ? `${main}${separator}${sub}` : main;
return ( return (
<Typography.Text <span className="admin-cell-line">
className="admin-cell-line"
ellipsis={{ tooltip: full }}
style={{ maxWidth: '100%' }}
>
{main} {main}
{sub ? ( {sub ? (
<span className="admin-cell-line-secondary"> <span className="admin-cell-line-secondary">
@@ -29,6 +22,6 @@ export function AdminCellLine({
{sub} {sub}
</span> </span>
) : null} ) : null}
</Typography.Text> </span>
); );
} }
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { Button, Checkbox, Modal, Space, Typography } from 'antd';
import { HolderOutlined } from '@ant-design/icons';
import type { ListColumnSettingItem } from '../lib/list-column-prefs';
type Props = {
open: boolean;
items: ListColumnSettingItem[];
saving?: boolean;
onCancel: () => void;
onReset: () => void;
onSave: (items: ListColumnSettingItem[]) => void;
};
export function AdminListColumnsModal({ open, items, saving, onCancel, onReset, onSave }: Props) {
const [draft, setDraft] = useState<ListColumnSettingItem[]>(items);
const [dragKey, setDragKey] = useState<string | null>(null);
useEffect(() => {
if (open) setDraft(items);
}, [open, items]);
function move(from: number, to: number) {
if (to < 0 || to >= draft.length) return;
const next = [...draft];
const [row] = next.splice(from, 1);
next.splice(to, 0, row);
setDraft(next);
}
return (
<Modal
title="列设置"
open={open}
onCancel={onCancel}
footer={
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button onClick={onReset}></Button>
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" loading={saving} onClick={() => onSave(draft)}>
</Button>
</Space>
</Space>
}
>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
</Typography.Paragraph>
<div>
{draft.map((item, index) => (
<div
key={item.key}
draggable
onDragStart={() => setDragKey(item.key)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (!dragKey || dragKey === item.key) return;
const from = draft.findIndex((r) => r.key === dragKey);
if (from >= 0) move(from, index);
setDragKey(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 0',
borderBottom: '1px solid #f0f0f0',
cursor: 'move',
}}
>
<HolderOutlined style={{ color: '#999' }} />
<Checkbox
checked={item.visible}
onChange={(e) => {
const next = [...draft];
next[index] = { ...item, visible: e.target.checked };
setDraft(next);
}}
>
{item.title}
</Checkbox>
<span style={{ marginLeft: 'auto' }}>
<Button type="link" size="small" disabled={index === 0} onClick={() => move(index, index - 1)}>
</Button>
<Button
type="link"
size="small"
disabled={index === draft.length - 1}
onClick={() => move(index, index + 1)}
>
</Button>
</span>
</div>
))}
</div>
</Modal>
);
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { Space, Typography } from 'antd';
/** 列表页顶栏:标题在左,主操作在右,列设置永远最右 */
export function AdminListHeader({
title,
description,
actions,
settings,
}: {
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
settings?: ReactNode;
}) {
return (
<div className="admin-list-header">
<div className="admin-list-header-left">
{title == null || title === '' ? null : typeof title === 'string' || typeof title === 'number' ? (
<Typography.Title level={4} style={{ margin: 0 }}>
{title}
</Typography.Title>
) : (
title
)}
{description ? (
<div className="admin-list-header-desc">{description}</div>
) : null}
</div>
<div className="admin-list-header-right">
{actions ? <Space wrap>{actions}</Space> : null}
{settings}
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import type { MouseEvent, ReactNode } from 'react';
/** HQ 主列表主展示列:下划线,点击进入编辑或详情 */
export function AdminPrimaryLink({
children,
onClick,
}: {
children?: ReactNode;
onClick: (e?: MouseEvent) => void;
}) {
const empty = children == null || children === '';
return (
<button
type="button"
className="admin-primary-link"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick(e);
}}
>
{empty ? '—' : children}
</button>
);
}
@@ -11,10 +11,23 @@ import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto; type PackageRow = StorePackageItemDto;
export type AdminStorePackagesHandle = { export type AdminStorePackagesHandle = {
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */ /** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>; saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
}; };
function mapLiveRows(live: StorePackagesResponse['live']): PackageRow[] {
return (live ?? []).map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
});
}
function emptyRow(index = 0): PackageRow { function emptyRow(index = 0): PackageRow {
return { return {
name: '', name: '',
@@ -37,6 +50,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null); const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
const itemsRef = useRef(items); const itemsRef = useRef(items);
const loadingRef = useRef(loading); const loadingRef = useRef(loading);
const dirtyRef = useRef(false);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -47,36 +61,33 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
loadingRef.current = loading; loadingRef.current = loading;
}, [loading]); }, [loading]);
function applyServerPackages(data: StorePackagesResponse) {
dirtyRef.current = false;
setPendingRequest(data.pendingRequest ?? null);
setItems(mapLiveRows(data.live));
}
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setPendingRequest(null); setPendingRequest(null);
dirtyRef.current = false;
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`) request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => { .then((data) => applyServerPackages(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 : '加载套餐失败')) .catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [storeId]); }, [storeId]);
// 审核页完成审核后,自动刷新本页「有待审核套餐」提醒 // 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐
useEffect(() => { useEffect(() => {
const onChanged = () => { const onChanged = () => {
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`) request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => setPendingRequest(data.pendingRequest ?? null)) .then((data) => {
setPendingRequest(data.pendingRequest ?? null);
if (!dirtyRef.current) {
dirtyRef.current = false;
setItems(mapLiveRows(data.live));
}
})
.catch(() => undefined); .catch(() => undefined);
}; };
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
@@ -108,16 +119,19 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
) : null; ) : null;
function updateAt(index: number, patch: Partial<PackageRow>) { function updateAt(index: number, patch: Partial<PackageRow>) {
dirtyRef.current = true;
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
} }
function addRow() { function addRow() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return; if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
dirtyRef.current = true;
setItems((prev) => [...prev, emptyRow(prev.length)]); setItems((prev) => [...prev, emptyRow(prev.length)]);
} }
function removeAt(index: number) { function removeAt(index: number) {
const run = () => { const run = () => {
dirtyRef.current = true;
setItems((prev) => { setItems((prev) => {
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })); const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
return next.length ? next : []; return next.length ? next : [];
@@ -203,20 +217,8 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
}), }),
}); });
if (!opts?.quiet) message.success('套餐已保存并生效'); if (!opts?.quiet) message.success('套餐已保存并生效');
setItems( dirtyRef.current = false;
data.live?.length setItems(mapLiveRows(data.live));
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
} catch (e) { } catch (e) {
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败'); if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
throw e; throw e;
@@ -227,7 +229,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
saveIfLoaded: async (opts) => { saveIfLoaded: async (opts) => {
if (loadingRef.current) return { skipped: true }; if (loadingRef.current || !dirtyRef.current) return { skipped: true };
await save(opts); await save(opts);
return { skipped: false }; return { skipped: false };
}, },
@@ -1,459 +1,458 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Checkbox, Checkbox,
Drawer, Drawer,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
Select, Select,
Space, Space,
Table, Table,
Tabs, Tabs,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
CITY_PARTNER_SCOPE_LABELS, CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS, CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType, CityPartnerScopeType,
CityPartnerStatus, CityPartnerStatus,
PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS, PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS, PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect'; import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList'; import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = { type PartnerRow = {
id: string; id: string;
companyName: string; companyName: string;
phone: string; phone: string;
name: string; name: string;
scopeType?: string; scopeType?: string;
districtCodes?: string[] | null; districtCodes?: string[] | null;
orderCommissionRate?: number; orderCommissionRate?: number;
redeemCommissionRate?: number; redeemCommissionRate?: number;
accountCount: number; accountCount: number;
createdAt: string; createdAt: string;
}; };
type PartnerDetail = PartnerRow & { type PartnerDetail = PartnerRow & {
address?: string; address?: string;
districtCodes?: string[] | null; districtCodes?: string[] | null;
bindingStatus?: string; bindingStatus?: string;
bankAccountName?: string | null; bankAccountName?: string | null;
bankAccountNo?: string | null; bankAccountNo?: string | null;
bankBranch?: string | null; bankBranch?: string | null;
managedWarehouseId?: string | null; managedWarehouseId?: string | null;
managedWarehouseName?: string | null; managedWarehouseName?: string | null;
contactPhone?: string | null; contactPhone?: string | null;
children?: Array<{ children?: Array<{
id: string; id: string;
phone: string; phone: string;
name: string; name: string;
staffRole?: string; staffRole?: string;
permissions?: string[]; permissions?: string[];
status: string; status: string;
}>; }>;
}; };
type Props = { type Props = {
cityId: string; cityId: string;
cityCode?: string; cityCode?: string;
maxPartnerCommissionRate?: number; maxPartnerCommissionRate?: number;
onChanged?: () => void; onChanged?: () => void;
}; };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label })); const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label })); const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k, value: k,
label: PARTNER_PERMISSION_LABELS[k], label: PARTNER_PERMISSION_LABELS[k],
})); }));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS) const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER') .filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label })); .map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] { function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return []; if (!values?.length) return [];
if (Array.isArray(values[0])) { if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean); return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
} }
return values as string[]; return values as string[];
} }
function commissionSumError( function commissionSumError(
orderPercent: number, orderPercent: number,
redeemPercent: number, redeemPercent: number,
maxRate: number, maxRate: number,
): string | null { ): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限 // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate); const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100; const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效'; if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) { if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`; return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
} }
return null; return null;
} }
function formatApiError(err: unknown): string | null { function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null; if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败'; if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => { return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code); const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code; return label !== code ? `${label}(${code})` : code;
}); });
} }
export default function CityPartnersPanel({ export default function CityPartnersPanel({
cityId, cityId,
cityCode, cityCode,
maxPartnerCommissionRate = 0.05, maxPartnerCommissionRate = 0.05,
onChanged, onChanged,
}: Props) { }: Props) {
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm(); const [subEditForm] = Form.useForm();
const [partners, setPartners] = useState<PartnerRow[]>([]); const [partners, setPartners] = useState<PartnerRow[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<PartnerDetail | null>(null); const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false); const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false); const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null); const [subEditId, setSubEditId] = useState<string | null>(null);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const loadPartners = useCallback(async () => { const loadPartners = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`); const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
setPartners(res.items); setPartners(res.items);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [cityId]); }, [cityId]);
useEffect(() => { useEffect(() => {
void loadPartners(); void loadPartners();
}, [loadPartners]); }, [loadPartners]);
async function openPartner(id: string) { async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`); const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d); setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
editForm.setFieldsValue({ editForm.setFieldsValue({
name: d.name, name: d.name,
phone: d.phone, phone: d.phone,
companyName: d.companyName, companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone, contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '', address: d.address ?? '',
scopeType: d.scopeType, scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [], districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100, orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100, redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE, bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '', bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '', bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '', bankBranch: d.bankBranch ?? '',
}); });
setDrawerOpen(true); setDrawerOpen(true);
} }
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
try { try {
const v = await editForm.validateFields(); const v = await editForm.validateFields();
const err = commissionSumError( const err = commissionSumError(
Number(v.orderCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0), Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate, maxPartnerCommissionRate,
); );
if (err) { if (err) {
message.error(err); message.error(err);
return; return;
} }
await request(`/admin/partners/${detail.id}`, { await request(`/admin/partners/${detail.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
...v, ...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}), }),
}); });
message.success('已保存'); message.success('已保存');
setDrawerOpen(false); setDrawerOpen(false);
void loadPartners(); void loadPartners();
onChanged?.(); onChanged?.();
} catch (e) { } catch (e) {
const msg = formatApiError(e); const msg = formatApiError(e);
if (msg) message.error(msg); if (msg) message.error(msg);
} }
} }
async function deleteSubAccount(subId: string) { async function deleteSubAccount(subId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' }); await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除'); message.success('子账号已删除');
if (detail) void openPartner(detail.id); if (detail) void openPartner(detail.id);
void loadPartners(); void loadPartners();
onChanged?.(); onChanged?.();
} }
const columns: ColumnsType<PartnerRow> = [ const columns: ColumnsType<PartnerRow> = [
{ {
title: '区县', title: '区县',
dataIndex: 'districtCodes', dataIndex: 'districtCodes',
width: 160, width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) => render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
}, },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true }, { title: '公司名', dataIndex: 'companyName' },
{ title: '主账号', dataIndex: 'phone', width: 120 }, { title: '主账号', dataIndex: 'phone', width: 120 },
{ {
title: '管辖', title: '管辖',
dataIndex: 'scopeType', dataIndex: 'scopeType',
width: 100, width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
}, },
{ {
title: '佣金', title: '佣金',
width: 120, width: 120,
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`, render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
}, },
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 80, width: 80,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}> <Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button> </Button>
), ),
}, },
]; ];
return ( return (
<> <>
<Button <Button
type="primary" type="primary"
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
onClick={() => { onClick={() => {
createForm.resetFields(); createForm.resetFields();
createForm.setFieldsValue({ createForm.setFieldsValue({
cityId, cityId,
orderCommissionRate: 0, orderCommissionRate: 0,
redeemCommissionRate: 3, redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE, scopeType: CityPartnerScopeType.CITY_WIDE,
}); });
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
</Button> </Button>
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} /> <Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
<Drawer <Drawer
title="城市合伙人" title="城市合伙人"
width={640} width={640}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>} extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
> >
{detail && ( {detail && (
<Tabs <Tabs
items={[ items={[
{ {
key: 'info', key: 'info',
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item> <Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)" extra="仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={cityCode} /> <CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space> </Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}% + {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text> </Typography.Text>
<Form.Item label="管仓仓库"> <Form.Item label="管仓仓库">
<Input <Input
disabled disabled
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'} value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
/> />
</Form.Item> </Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item> <Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item> <Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item> <Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form> </Form>
), ),
}, },
{ {
key: 'staff', key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`, label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: ( children: (
<PartnerSubAccountList <PartnerSubAccountList
subs={detail.children ?? []} subs={detail.children ?? []}
onAdd={() => { onAdd={() => {
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
}} }}
onEdit={(row) => { onEdit={(row) => {
setSubEditId(row.id); setSubEditId(row.id);
subEditForm.setFieldsValue({ subEditForm.setFieldsValue({
name: row.name, name: row.name,
phone: row.phone, phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL', staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [], permissions: row.permissions ?? [],
status: row.status, status: row.status,
}); });
setSubEditOpen(true); setSubEditOpen(true);
}} }}
onDelete={(subId) => void deleteSubAccount(subId)} onDelete={(subId) => void deleteSubAccount(subId)}
/> />
), ),
}, },
]} ]}
/> />
)} )}
</Drawer> </Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
try { try {
const v = await createForm.validateFields(); const v = await createForm.validateFields();
const err = commissionSumError( const err = commissionSumError(
Number(v.orderCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3), Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate, maxPartnerCommissionRate,
); );
if (err) { if (err) {
message.error(err); message.error(err);
return; return;
} }
await request('/admin/partners', { await request('/admin/partners', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
...v, ...v,
cityId, cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}), }),
}); });
message.success('已创建'); message.success('已创建');
setCreateOpen(false); setCreateOpen(false);
void loadPartners(); void loadPartners();
onChanged?.(); onChanged?.();
} catch (e) { } catch (e) {
const msg = formatApiError(e); const msg = formatApiError(e);
if (msg) message.error(msg); if (msg) message.error(msg);
} }
}}> }}>
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item> <Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item <Form.Item
name="districtCodes" name="districtCodes"
label="区县" label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)" extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
> >
<CityDistrictMultiSelect cityCode={cityCode} /> <CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item> <Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space> </Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}% + {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text> </Typography.Text>
</Form> </Form>
</Modal> </Modal>
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => { <Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
if (!detail) return; if (!detail) return;
const v = await subForm.validateFields(); const v = await subForm.validateFields();
await request('/admin/partner-accounts', { await request('/admin/partner-accounts', {
method: 'POST', method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }), body: JSON.stringify({ ...v, parentAccountId: detail.id }),
}); });
message.success('已创建'); message.success('已创建');
setSubOpen(false); setSubOpen(false);
void openPartner(detail.id); void openPartner(detail.id);
void loadPartners(); void loadPartners();
onChanged?.(); onChanged?.();
}}> }}>
<Form form={subForm} layout="vertical"> <Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL"> <Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} /> <Select options={STAFF_ROLE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="permissions" label="权限"> <Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} /> <Checkbox.Group options={PERM_OPTIONS} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => { <Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
if (!subEditId || !detail) return; if (!subEditId || !detail) return;
const v = await subEditForm.validateFields(); const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) }); await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新'); message.success('已更新');
setSubEditOpen(false); setSubEditOpen(false);
void openPartner(detail.id); void openPartner(detail.id);
}}> }}>
<Form form={subEditForm} layout="vertical"> <Form form={subEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item> <Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} /> <Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
</Form.Item> </Form.Item>
<Form.Item name="permissions" label="权限"> <Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} /> <Checkbox.Group options={PERM_OPTIONS} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
</> </>
); );
}
@@ -173,7 +173,9 @@ export default function OrderTrackDrawer({
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={ description={
track?.manualQueryUrl track?.queryError
? track.queryError
: track?.manualQueryUrl
? '暂无实时路由节点,可使用上方物流查询链接' ? '暂无实时路由节点,可使用上方物流查询链接'
: '暂无路由信息,请稍后刷新' : '暂无路由信息,请稍后刷新'
} }
@@ -1,92 +1,91 @@
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd'; import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
PARTNER_PERMISSION_LABELS, PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS, PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
export type PartnerSubAccountRow = { export type PartnerSubAccountRow = {
id: string; id: string;
phone: string; phone: string;
name: string; name: string;
staffRole?: string; staffRole?: string;
permissions?: string[]; permissions?: string[];
status: string; status: string;
}; };
function formatPermissions(permissions?: string[]) { function formatPermissions(permissions?: string[]) {
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—'; return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
} }
type Props = { type Props = {
subs: PartnerSubAccountRow[]; subs: PartnerSubAccountRow[];
onAdd: () => void; onAdd: () => void;
onEdit: (sub: PartnerSubAccountRow) => void; onEdit: (sub: PartnerSubAccountRow) => void;
onDelete: (subId: string) => void; onDelete: (subId: string) => void;
}; };
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) { export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
const columns: ColumnsType<PartnerSubAccountRow> = [ const columns: ColumnsType<PartnerSubAccountRow> = [
{ title: '姓名', dataIndex: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '角色', title: '角色',
dataIndex: 'staffRole', dataIndex: 'staffRole',
width: 90, width: 90,
render: (v) => render: (v) =>
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—', v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 80, width: 80,
render: (s) => ( render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag> <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
), ),
}, },
{ {
title: '权限', title: '权限',
dataIndex: 'permissions', dataIndex: 'permissions',
ellipsis: true,
render: (p: string[] | undefined) => formatPermissions(p), render: (p: string[] | undefined) => formatPermissions(p),
}, },
{ {
title: '操作', title: '操作',
width: 120, width: 120,
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" onClick={() => onEdit(row)}> <Button type="link" size="small" onClick={() => onEdit(row)}>
</Button> </Button>
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}> <Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
<Button type="link" size="small" danger> <Button type="link" size="small" danger>
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
}, },
]; ];
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{subs.length}· {subs.length}·
</Typography.Text> </Typography.Text>
<Button size="small" type="primary" onClick={onAdd}> <Button size="small" type="primary" onClick={onAdd}>
</Button> </Button>
</div> </div>
<Table <Table
size="small" size="small"
rowKey="id" rowKey="id"
pagination={false} pagination={false}
columns={columns} columns={columns}
dataSource={subs} dataSource={subs}
locale={{ emptyText: '暂无子账号' }} locale={{ emptyText: '暂无子账号' }}
/> />
</> </>
); );
}
@@ -0,0 +1,616 @@
import { useMemo, useState } from 'react';
import {
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
} from 'antd';
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { request } from '../lib/api';
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
import OssUpload from './OssUpload';
type SpecValue = { id?: string; name: string; sortOrder?: number };
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
type SkuRow = {
id?: string;
specValueIds: string[];
skuCode?: string;
barcode69: string;
price: number;
benefitAmount?: number;
status: string;
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
allowOnSitePickup: boolean;
saleUnit: 'BOTTLE' | 'BOX';
bottlesPerUnit: number;
isDefault: boolean;
sortOrder?: number;
specText?: string;
imageUrl?: string | null;
};
function cartesian(attrs: SpecAttr[]): string[][] {
if (!attrs.length) return [[]];
return attrs.reduce<string[][]>(
(acc, attr) => {
const next: string[][] = [];
const values = attr.values.filter((v) => v.name?.trim());
for (const prev of acc) {
for (const v of values) {
const vid = v.id || `__new__:${attr.name}:${v.name}`;
next.push([...prev, vid]);
}
}
return next.length ? next : acc;
},
[[]],
);
}
function keyOf(ids: string[]) {
return [...ids].sort().join('_');
}
type Props = {
productId: string;
initialAttrs: SpecAttr[];
initialSkus: SkuRow[];
onSaved: () => void;
};
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
const [attrs, setAttrs] = useState<SpecAttr[]>(
initialAttrs.length
? initialAttrs
: [],
);
const [skus, setSkus] = useState<SkuRow[]>(
initialSkus.length
? initialSkus.map((s) => ({
...s,
specValueIds: s.specValueIds ?? [],
status: s.status || 'ON_SALE',
allowOnlinePurchase: s.allowOnlinePurchase !== false,
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
allowOnSitePickup: !!s.allowOnSitePickup,
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
isDefault: !!s.isDefault,
imageUrl: s.imageUrl ?? '',
}))
: [
{
specValueIds: [],
barcode69: '',
price: 0,
status: 'ON_SALE',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
imageUrl: '',
},
],
);
const [saving, setSaving] = useState(false);
const [editIndex, setEditIndex] = useState<number | null>(null);
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
function regenerateMatrix() {
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
const next: SkuRow[] = combos.map((ids, i) => {
const existing = byKey.get(keyOf(ids));
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
return {
specValueIds: ids,
barcode69: '',
price: skus[0]?.price ?? 0,
benefitAmount: skus[0]?.benefitAmount,
status: 'DRAFT',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: i === 0,
sortOrder: i,
imageUrl: '',
};
});
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
setSkus(next.length ? next : skus);
}
function patchSku(index: number, patch: Partial<SkuRow>) {
setSkus((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...patch };
return next;
});
}
async function handleSave() {
setSaving(true);
try {
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
const specsRes = await request<{
specAttrs: SpecAttr[];
skus: SkuRow[];
}>(`/admin/products/${productId}/specs`, {
method: 'PUT',
body: JSON.stringify({
attrs: attrs.map((a, i) => ({
id: a.id,
name: a.name.trim(),
sortOrder: i,
values: a.values
.filter((v) => v.name.trim())
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
})),
}),
});
// 映射临时 id → 真实 id
const nameToId = new Map<string, string>();
for (const a of specsRes.specAttrs ?? []) {
for (const v of a.values ?? []) {
nameToId.set(`${a.name}:${v.name}`, v.id!);
}
}
const resolveIds = (ids: string[]) =>
ids.map((id) => {
if (!id.startsWith('__new__:')) return id;
const [, attrName, valueName] = id.split(':');
const real = nameToId.get(`${attrName}:${valueName}`);
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
return real;
});
const payloadSkus = skus.map((s, i) => ({
id: s.id,
specValueIds: resolveIds(s.specValueIds ?? []),
barcode69: s.barcode69.trim(),
price: s.price,
benefitAmount: s.benefitAmount,
status: s.status,
allowOnlinePurchase: s.allowOnlinePurchase,
allowCrossCityDelivery: s.allowCrossCityDelivery,
allowOnSitePickup: s.allowOnSitePickup,
saleUnit: s.saleUnit,
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
isDefault: !!s.isDefault,
sortOrder: i,
imageUrl: s.imageUrl?.trim() || null,
}));
const barcodes = payloadSkus.map((row) => row.barcode69);
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
if (new Set(barcodes).size !== barcodes.length) {
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
}
await request(`/admin/products/${productId}/skus`, {
method: 'PUT',
body: JSON.stringify({ skus: payloadSkus }),
});
message.success('规格与 SKU 已保存');
onSaved();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
const editing = editIndex != null ? skus[editIndex] : null;
return (
<div>
<Typography.Paragraph type="secondary">
SKU DK <strong></strong> 69
SKU
</Typography.Paragraph>
<Typography.Title level={5}></Typography.Title>
{attrs.map((attr, ai) => (
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Space align="start" style={{ width: '100%' }} wrap>
<Input
style={{ width: 140 }}
placeholder="规格名"
value={attr.name}
onChange={(e) => {
const next = [...attrs];
next[ai] = { ...attr, name: e.target.value };
setAttrs(next);
}}
/>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
/>
</Space>
<div style={{ marginTop: 8 }}>
{attr.values.map((val, vi) => (
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
<Input
placeholder="规格值"
value={val.name}
onChange={(e) => {
const next = [...attrs];
const values = [...attr.values];
values[vi] = { ...val, name: e.target.value };
next[ai] = { ...attr, values };
setAttrs(next);
}}
/>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => {
const next = [...attrs];
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
setAttrs(next);
}}
/>
</Space>
))}
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={() => {
const next = [...attrs];
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
setAttrs(next);
}}
>
</Button>
</div>
</div>
))}
<Space style={{ marginBottom: 16 }}>
<Button
icon={<PlusOutlined />}
disabled={attrs.length >= 3}
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
>
</Button>
<Button onClick={regenerateMatrix}> SKU </Button>
</Space>
<Typography.Title level={5}>SKU </Typography.Title>
<Table
size="small"
rowKey={(_, i) => String(i)}
pagination={false}
scroll={{ x: 1240 }}
dataSource={skus}
columns={[
{
title: '规格',
width: 140,
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
},
{
title: 'SKU',
width: 110,
render: (_, row) => (
<Typography.Text type="secondary">
{row.skuCode || '保存后自动生成'}
</Typography.Text>
),
},
{
title: <span>69 <Typography.Text type="danger">*</Typography.Text></span>,
width: 180,
render: (_, row, index) => (
<Input
placeholder="本规格独立 69 码"
value={row.barcode69}
onChange={(e) => {
const next = [...skus];
next[index] = { ...row, barcode69: e.target.value };
setSkus(next);
}}
/>
),
},
{
title: '售价',
width: 100,
render: (_, row, index) => (
<InputNumber
min={0}
style={{ width: '100%' }}
value={row.price}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, price: Number(v) || 0 };
setSkus(next);
}}
/>
),
},
{
title: '权益',
width: 100,
render: (_, row, index) => (
<InputNumber
min={0}
style={{ width: '100%' }}
value={row.benefitAmount}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
setSkus(next);
}}
/>
),
},
{
title: '状态',
width: 110,
render: (_, row, index) => (
<Select
style={{ width: '100%' }}
value={row.status}
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={(status) => {
const next = [...skus];
next[index] = { ...row, status };
setSkus(next);
}}
/>
),
},
{
title: '单位',
width: 160,
render: (_, row, index) => (
<Space>
<Select
style={{ width: 80 }}
value={row.saleUnit}
options={[
{ value: 'BOTTLE', label: '瓶' },
{ value: 'BOX', label: '箱' },
]}
onChange={(saleUnit) => {
const next = [...skus];
next[index] = {
...row,
saleUnit,
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
};
setSkus(next);
}}
/>
{row.saleUnit === 'BOX' ? (
<InputNumber
min={1}
style={{ width: 70 }}
value={row.bottlesPerUnit}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
setSkus(next);
}}
/>
) : null}
</Space>
),
},
{
title: '履约',
width: 200,
render: (_, row, index) => (
<Space direction="vertical" size={0}>
<Switch
checkedChildren="线上"
unCheckedChildren="线上"
checked={row.allowOnlinePurchase}
onChange={(checked) => {
const next = [...skus];
next[index] = {
...row,
allowOnlinePurchase: checked,
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
};
setSkus(next);
}}
/>
<Switch
checkedChildren="跨城"
unCheckedChildren="跨城"
disabled={!row.allowOnlinePurchase}
checked={row.allowCrossCityDelivery}
onChange={(checked) => {
const next = [...skus];
next[index] = { ...row, allowCrossCityDelivery: checked };
setSkus(next);
}}
/>
<Switch
checkedChildren="现场"
unCheckedChildren="现场"
checked={row.allowOnSitePickup}
onChange={(checked) => {
const next = [...skus];
next[index] = { ...row, allowOnSitePickup: checked };
setSkus(next);
}}
/>
</Space>
),
},
{
title: '默认',
width: 70,
render: (_, row, index) => (
<Radio
checked={row.isDefault}
onChange={() => {
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
}}
/>
),
},
{
title: '主图',
width: 72,
render: (_, row) =>
row.imageUrl ? (
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '编辑',
width: 80,
fixed: 'right',
render: (_, row, index) => (
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
</Button>
),
},
]}
/>
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
SKU
</Button>
<Modal
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
open={editIndex != null}
onCancel={() => setEditIndex(null)}
onOk={() => setEditIndex(null)}
okText="完成"
width={560}
destroyOnClose
>
{editing && editIndex != null ? (
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<div>
<Typography.Text type="secondary">SKU</Typography.Text>
<div>{editing.skuCode || '保存后自动生成'}</div>
</div>
<div>
<Typography.Text>69 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
<Input
placeholder="本规格独立 69 码"
value={editing.barcode69}
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
/>
</div>
<div>
<Typography.Text></Typography.Text>
<OssUpload
bizType="COVER"
value={editing.imageUrl || ''}
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
/>
</div>
<Space>
<div>
<Typography.Text></Typography.Text>
<InputNumber
min={0}
style={{ width: 140 }}
value={editing.price}
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
/>
</div>
<div>
<Typography.Text></Typography.Text>
<InputNumber
min={0}
style={{ width: 140 }}
value={editing.benefitAmount}
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
/>
</div>
</Space>
<div>
<Typography.Text></Typography.Text>
<Select
style={{ width: '100%' }}
value={editing.status}
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={(status) => patchSku(editIndex, { status })}
/>
</div>
<Space>
<Select
style={{ width: 100 }}
value={editing.saleUnit}
options={[
{ value: 'BOTTLE', label: '瓶' },
{ value: 'BOX', label: '箱' },
]}
onChange={(saleUnit) =>
patchSku(editIndex, {
saleUnit,
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
})
}
/>
{editing.saleUnit === 'BOX' ? (
<InputNumber
min={1}
value={editing.bottlesPerUnit}
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
/>
) : null}
</Space>
<Space>
<Switch
checkedChildren="线上"
unCheckedChildren="线上"
checked={editing.allowOnlinePurchase}
onChange={(checked) =>
patchSku(editIndex, {
allowOnlinePurchase: checked,
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
})
}
/>
<Switch
checkedChildren="跨城"
unCheckedChildren="跨城"
disabled={!editing.allowOnlinePurchase}
checked={editing.allowCrossCityDelivery}
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
/>
<Switch
checkedChildren="现场"
unCheckedChildren="现场"
checked={editing.allowOnSitePickup}
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
/>
<Switch
checkedChildren="默认"
unCheckedChildren="默认"
checked={editing.isDefault}
onChange={(checked) => {
if (!checked) return;
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
}}
/>
</Space>
</Space>
) : null}
</Modal>
</div>
);
}
@@ -51,6 +51,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
const [regionCodes, setRegionCodes] = useState<string[]>([]); const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState(''); const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState<string>(); const [productId, setProductId] = useState<string>();
const [skuId, setSkuId] = useState<string>();
const [quantity, setQuantity] = useState(2); const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>(); const [promoCodeId, setPromoCodeId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS'); const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
@@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
}, [open]); }, [open]);
const selectedProduct = options?.products.find((p) => p.id === productId); const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true; const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const allowOnSite = !!selectedProduct?.allowOnSitePickup; const selectedSku =
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true; skuOptions.find((s) => s.id === skuId) ||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
skuOptions[0];
const allowOnline = selectedSku
? selectedSku.allowOnlinePurchase !== false
: selectedProduct
? selectedProduct.allowOnlinePurchase !== false
: true;
const allowOnSite = selectedSku
? !!selectedSku.allowOnSitePickup
: !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedSku
? selectedSku.allowCrossCityDelivery !== false
: selectedProduct
? selectedProduct.allowCrossCityDelivery !== false
: true;
useEffect(() => {
if (!open || !selectedProduct) return;
const def =
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
skuOptions.find((s) => s.isDefault) ||
skuOptions[0];
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
setSkuId(def.id);
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, productId, selectedProduct?.id]);
useEffect(() => { useEffect(() => {
if (!open || !selectedProduct) return; if (!open || !selectedProduct) return;
@@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId, productId,
quantity, quantity,
deliveryMode, deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined, receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined, receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}), }),
@@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
.finally(() => setPreviewLoading(false)); .finally(() => setPreviewLoading(false));
}, 300); }, 300);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]); }, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
useEffect(() => () => stopPoll(), []); useEffect(() => () => stopPoll(), []);
@@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setRegionCodes([]); setRegionCodes([]);
setAddressDetail(''); setAddressDetail('');
setQuantity(2); setQuantity(2);
setProductId(undefined);
setSkuId(undefined);
setPromoCodeId(undefined); setPromoCodeId(undefined);
setDeliveryMode('ADDRESS'); setDeliveryMode('ADDRESS');
setAutoReceive(false); setAutoReceive(false);
@@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId: productId!, productId: productId!,
quantity, quantity,
promoCodeId: promoCodeId || undefined, promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
}; };
setSubmitting(true); setSubmitting(true);
@@ -217,6 +250,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
method: 'POST', method: 'POST',
body: JSON.stringify({ payMethod: 'NATIVE' }), body: JSON.stringify({ payMethod: 'NATIVE' }),
}); });
if (pay.mode === 'mock') {
message.success(`支付成功:${order.orderNo}`);
resetForm();
onSuccess({ id: order.id, orderNo: order.orderNo });
return;
}
setCodeUrl(pay.codeUrl ?? null); setCodeUrl(pay.codeUrl ?? null);
startPoll(order.id); startPoll(order.id);
} catch (e) { } catch (e) {
@@ -311,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
loading={loadingOptions} loading={loadingOptions}
placeholder="选择商品" placeholder="选择商品"
value={productId} value={productId}
onChange={setProductId} onChange={(id) => {
setProductId(id);
setSkuId(undefined);
}}
options={(options?.products ?? []).map((p) => ({ options={(options?.products ?? []).map((p) => ({
value: p.id, value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`, label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
@@ -319,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
/> />
</Form.Item> </Form.Item>
<Form.Item label="数量" required> {skuOptions.length > 1 || selectedProduct?.specEnabled ? (
<Form.Item label="规格" required>
<Select
placeholder="选择规格"
value={skuId}
onChange={(id) => {
setSkuId(id);
const sku = skuOptions.find((s) => s.id === id);
if (sku?.saleUnit === 'BOX') setQuantity(1);
}}
options={skuOptions.map((s) => ({
value: s.id,
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
<InputNumber <InputNumber
min={1} min={1}
value={quantity} value={quantity}
@@ -23,6 +23,11 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
cityName?: string; cityName?: string;
address?: string; address?: string;
partnerAccount?: { companyName?: string }; partnerAccount?: { companyName?: string };
primaryAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
} }
| undefined; | undefined;
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined; const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
@@ -46,6 +51,15 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item> <Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item> <Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="收款户名">
{store?.primaryAccount?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="收款账号">
{store?.primaryAccount?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{store?.primaryAccount?.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? ( {Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
@@ -0,0 +1,459 @@
import { useEffect, useState } from 'react';
import { Button, Image, Input, Modal, Space, Tag, Typography, message } from 'antd';
import type {
StorePackageAuditDetailDto,
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
const HQ_PACKAGE_STATUS_LABELS: Record<string, 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' };
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]));
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
const rows: Array<{
key: string;
change: 'added' | 'removed' | 'changed' | 'unchanged';
live?: StorePackageViewDto;
proposed?: StorePackageItemDto;
changes?: FieldChange[];
}> = [];
for (const key of keys) {
const l = liveMap.get(key);
const p = proposedMap.get(key);
if (l && !p) {
rows.push({ key, change: 'removed', live: l });
} else if (!l && p) {
rows.push({ key, change: 'added', proposed: p });
} else if (l && p) {
const changed =
l.price !== p.price ||
l.dishes !== p.dishes ||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
(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;
}
const CHANGE_LABELS = {
added: { text: '新增', color: 'green' },
removed: { text: '删除', color: 'red' },
changed: { text: '变更', color: 'orange' },
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,
imageSize = 72,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
change?: keyof typeof CHANGE_LABELS;
changes?: FieldChange[];
imageSize?: number;
}) {
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={imageSize} height={imageSize} 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 type StorePackageAuditPanelProps = {
requestId: string;
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
showActions?: boolean;
fullscreen?: boolean;
onAudited?: () => void;
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
};
/** 套餐变更对比 + 通过/驳回(审核通知页与门店详情抽屉共用) */
export default function StorePackageAuditPanel({
requestId,
showActions = true,
fullscreen = false,
onAudited,
onDetailLoaded,
}: StorePackageAuditPanelProps) {
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [auditing, setAuditing] = useState(false);
async function load() {
setLoading(true);
try {
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${requestId}`);
setDetail(data);
onDetailLoaded?.(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载套餐审核详情失败');
setDetail(null);
onDetailLoaded?.(null);
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [requestId]);
async function audit(action: 'APPROVE' | 'REJECT', reason?: string) {
if (!detail) return;
setAuditing(true);
try {
await request(`/admin/store-package-audits/${detail.id}/audit`, {
method: 'PUT',
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason: reason } : { action }),
});
message.success(action === 'APPROVE' ? '套餐已通过' : '套餐已驳回');
notifyPackageAuditChanged();
onAudited?.();
await load();
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
} finally {
setAuditing(false);
}
}
if (loading && !detail) {
return <Typography.Text type="secondary"></Typography.Text>;
}
if (!detail) {
return <Typography.Text type="secondary"></Typography.Text>;
}
const diffRows = diffPackages(detail.livePackages ?? [], detail.packages ?? []);
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;
return (
<div>
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }} wrap>
<Space wrap>
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
</Typography.Text>
</Space>
{showActions && detail.status === 'PENDING' ? (
<Space>
<Button type="primary" loading={auditing} onClick={() => void audit('APPROVE')}>
</Button>
<Button
danger
loading={auditing}
onClick={() => {
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null}
</Space>
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<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))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
<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))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</div>
<Modal
title="驳回套餐变更"
open={rejectOpen}
confirmLoading={auditing}
onCancel={() => setRejectOpen(false)}
onOk={() => {
if (!rejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false));
}}
>
<Input.TextArea
rows={3}
value={rejectReason}
placeholder="驳回原因"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
/** 供门店详情抽屉 extra 调用 */
export async function auditStorePackageRequest(
requestId: string,
action: 'APPROVE' | 'REJECT',
rejectReason?: string,
) {
await request(`/admin/store-package-audits/${requestId}/audit`, {
method: 'PUT',
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason } : { action }),
});
notifyPackageAuditChanged();
}
+47 -24
View File
@@ -20,13 +20,22 @@ import {
AccountBookOutlined, AccountBookOutlined,
ProjectOutlined, ProjectOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { hasAnySystemSettingsPermission } from '@dukang/shared-types'; import {
hasAnySystemSettingsPermission,
hasAnyStoreMenuPermission,
HQ_ADMIN_ROLES,
} from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api'; import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events'; import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
);
type MenuItem = NonNullable<MenuProps['items']>[number]; type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [ const MENU_ITEMS: MenuProps['items'] = [
@@ -61,7 +70,7 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '门店', label: '门店',
children: [ children: [
{ key: '/stores', label: '门店列表' }, { key: '/stores', label: '门店列表' },
{ key: '/store-package-audits', label: '套餐审核' }, { key: '/store-package-audits', label: '审核通知' },
{ key: '/store-ratings', label: '门店评价' }, { key: '/store-ratings', label: '门店评价' },
{ key: '/store-categories', label: '门店分类' }, { key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' }, { key: '/store-accounts', label: '门店账户' },
@@ -156,6 +165,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
if (key === 'tickets-group') { if (key === 'tickets-group') {
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support'); return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
} }
if (key === 'stores-group') {
return hasAnyStoreMenuPermission(permissionKeys);
}
if (key === 'dev-plan-group') { if (key === 'dev-plan-group') {
return permissionKeys.includes('dev_plan'); return permissionKeys.includes('dev_plan');
} }
@@ -175,11 +187,11 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/knowledge-bases': 'knowledge_bases', '/knowledge-bases': 'knowledge_bases',
'stores-group': 'stores', 'stores-group': 'stores',
'/stores': 'stores', '/stores': 'stores',
'/store-package-audits': 'stores', '/store-package-audits': 'store_audits',
'/store-ratings': 'stores', '/store-ratings': 'store_ratings',
'/store-categories': 'stores', '/store-categories': 'store_categories',
'/store-accounts': 'stores', '/store-accounts': 'store_accounts',
'/store-media': 'stores', '/store-media': 'store_media',
'partners-group': 'partners', 'partners-group': 'partners',
'/cities': 'partners', '/cities': 'partners',
'/city-partners': 'partners', '/city-partners': 'partners',
@@ -196,10 +208,10 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/benefit/ledgers': 'benefit', '/benefit/ledgers': 'benefit',
'/redeem-records': 'benefit', '/redeem-records': 'benefit',
'/redeem-pending': 'benefit', '/redeem-pending': 'benefit',
'/redeem/debug': 'benefit', '/redeem/debug': 'benefit_debug',
'deliveries-group': 'deliveries', 'deliveries-group': 'deliveries',
'/deliveries': 'deliveries', '/deliveries': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries', '/deliveries/xiaofeixia': 'deliveries_debug',
'/tickets': 'tickets', '/tickets': 'tickets',
'/tickets/support': 'tech_support', '/tickets/support': 'tech_support',
'dev-plan-group': 'dev_plan', 'dev-plan-group': 'dev_plan',
@@ -244,14 +256,14 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
.filter(Boolean) as MenuProps['items']; .filter(Boolean) as MenuProps['items'];
} }
function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] { function attachAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
if (!items) return items; if (!items) return items;
return items.map((item) => { return items.map((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) return item; if (!item || typeof item !== 'object' || !('key' in item)) return item;
if ('children' in item && Array.isArray(item.children)) { if ('children' in item && Array.isArray(item.children)) {
return { return {
...item, ...item,
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount), children: attachAuditBadge(item.children as MenuProps['items'], pendingCount),
} as MenuItem; } as MenuItem;
} }
if (String(item.key) === '/store-package-audits') { if (String(item.key) === '/store-package-audits') {
@@ -259,7 +271,7 @@ function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number
...item, ...item,
label: ( label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{pendingCount > 0 && <Badge count={pendingCount} size="small" />} {pendingCount > 0 && <Badge count={pendingCount} size="small" />}
</span> </span>
), ),
@@ -276,11 +288,14 @@ export default function AdminLayout() {
const location = useLocation(); const location = useLocation();
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
const [packagePendingCount, setPackagePendingCount] = useState(0); const [auditPendingCount, setAuditPendingCount] = useState(0);
function refreshPackagePendingCount() { function refreshAuditPendingCount() {
request<{ pendingCount: number }>('/admin/store-package-audits/summary') Promise.all([
.then((data) => setPackagePendingCount(data.pendingCount ?? 0)) request<{ pendingCount: number }>('/admin/store-package-audits/summary').catch(() => ({ pendingCount: 0 })),
request<{ pendingCount: number; packagePendingCount?: number }>('/admin/store-info-change-requests/summary').catch(() => ({ pendingCount: 0 })),
])
.then(([pkg, info]) => setAuditPendingCount((pkg.pendingCount ?? 0) + (info.pendingCount ?? 0)))
.catch(() => {}); .catch(() => {});
} }
@@ -289,13 +304,17 @@ export default function AdminLayout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshPackagePendingCount(); refreshAuditPendingCount();
}, [location.pathname]); }, [location.pathname]);
useEffect(() => { useEffect(() => {
const onChanged = () => refreshPackagePendingCount(); const onChanged = () => refreshAuditPendingCount();
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); window.addEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
return () => {
window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
window.removeEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -320,8 +339,8 @@ export default function AdminLayout() {
!profile || profile.adminRole === 'SUPER_ADMIN' !profile || profile.adminRole === 'SUPER_ADMIN'
? MENU_ITEMS ? MENU_ITEMS
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []); : filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
return attachPackageAuditBadge(base, packagePendingCount); return attachAuditBadge(base, auditPendingCount);
}, [profile, packagePendingCount]); }, [profile, auditPendingCount]);
return ( return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}> <Layout style={{ height: '100vh', overflow: 'hidden' }}>
@@ -394,7 +413,9 @@ export default function AdminLayout() {
</Typography.Title> </Typography.Title>
<Space> <Space>
<span>{profile?.name || '—'}</span> <span>{profile?.name || '—'}</span>
<span style={{ color: '#999' }}>{profile?.adminRole}</span> <span style={{ color: '#999' }}>
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
</span>
<Button type="text" icon={<LogoutOutlined />} onClick={logout}> <Button type="text" icon={<LogoutOutlined />} onClick={logout}>
退 退
</Button> </Button>
@@ -406,7 +427,9 @@ export default function AdminLayout() {
style={{ flex: 1, minHeight: 0, overflow: 'auto' }} style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
> >
<Content style={{ margin: 24 }}> <Content style={{ margin: 24 }}>
<Outlet /> <ListColumnPrefsProvider initial={profile?.listColumnPrefs}>
<Outlet />
</ListColumnPrefsProvider>
</Content> </Content>
</div> </div>
</Layout> </Layout>
@@ -0,0 +1,54 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { HqListColumnKey, HqListColumnPref, HqListColumnPrefsMap } from '@dukang/shared-types';
import { request } from './api';
type Ctx = {
prefs: HqListColumnPrefsMap;
ready: boolean;
setPrefs: (next: HqListColumnPrefsMap) => void;
save: (listKey: HqListColumnKey, next: HqListColumnPref | null) => Promise<void>;
};
const ListColumnPrefsContext = createContext<Ctx>({
prefs: {},
ready: false,
setPrefs: () => {},
save: async () => {},
});
export function ListColumnPrefsProvider({
initial,
children,
}: {
initial?: HqListColumnPrefsMap;
children: ReactNode;
}) {
const [prefs, setPrefs] = useState<HqListColumnPrefsMap>(initial ?? {});
const [ready] = useState(true);
useEffect(() => {
if (initial) setPrefs(initial);
}, [initial]);
const save = useCallback(async (listKey: HqListColumnKey, next: HqListColumnPref | null) => {
const res = await request<{ listColumnPrefs: HqListColumnPrefsMap }>(
`/admin/me/list-columns/${listKey}`,
{
method: 'PUT',
body: JSON.stringify(
next
? { order: next.order, hidden: next.hidden, widths: next.widths }
: { reset: true },
),
},
);
setPrefs(res.listColumnPrefs ?? {});
}, []);
const value = useMemo(() => ({ prefs, ready, setPrefs, save }), [prefs, ready, save]);
return <ListColumnPrefsContext.Provider value={value}>{children}</ListColumnPrefsContext.Provider>;
}
export function useListColumnPrefs() {
return useContext(ListColumnPrefsContext);
}
+6
View File
@@ -1,5 +1,11 @@
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed'; export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed';
export function notifyPackageAuditChanged() { export function notifyPackageAuditChanged() {
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT)); window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
window.dispatchEvent(new Event(AUDIT_NOTICE_CHANGED_EVENT));
}
export function notifyAuditNoticeChanged() {
notifyPackageAuditChanged();
} }
+24
View File
@@ -1,3 +1,5 @@
import type { HqListColumnPrefsMap } from '@dukang/shared-types';
export const apiBase = '/api/v1'; export const apiBase = '/api/v1';
export const CLIENT_APP = 'HQ_WEB'; export const CLIENT_APP = 'HQ_WEB';
@@ -8,6 +10,9 @@ export type HqProfile = {
adminRole: string; adminRole: string;
status: string; status: string;
permissionKeys?: string[]; permissionKeys?: string[];
cityIds?: string[];
cityScoped?: boolean;
listColumnPrefs?: HqListColumnPrefsMap;
}; };
export function getToken() { export function getToken() {
@@ -87,6 +92,9 @@ export type DashboardStats = {
openTickets?: number; openTickets?: number;
pendingStoreWithdrawals?: number; pendingStoreWithdrawals?: number;
overdueStoreWithdrawals?: number; overdueStoreWithdrawals?: number;
pendingStoreOnboard?: number;
pendingStorePackageAudits?: number;
pendingStoreInfoChanges?: number;
ordersByStatus: Array<{ status: string; count: number }>; ordersByStatus: Array<{ status: string; count: number }>;
}; };
@@ -161,6 +169,7 @@ export type AdminUserRow = {
wxOpenId: string | null; wxOpenId: string | null;
wechatVerified: boolean; wechatVerified: boolean;
nickname: string | null; nickname: string | null;
hqRemark: string | null;
status: number; status: number;
sourceType: string; sourceType: string;
sourceRefId: string | null; sourceRefId: string | null;
@@ -193,8 +202,21 @@ export type AdminOrderRow = {
productName?: string; productName?: string;
productSpec?: string; productSpec?: string;
quantity?: number; quantity?: number;
saleUnit?: string;
receiverName: string; receiverName: string;
receiverPhone: string; receiverPhone: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
receiverAddress?: string;
benefitAmount?: number;
benefitCoupon?: {
couponNo?: string;
totalAmount?: number;
usedAmount?: number;
balance?: number;
status?: string;
} | null;
createdAt: string; createdAt: string;
cityId?: string; cityId?: string;
city?: { id: string; name: string; code: string }; city?: { id: string; name: string; code: string };
@@ -214,5 +236,7 @@ export type AdminOrderRow = {
providerOrderNo: string | null; providerOrderNo: string | null;
logisticsCompany?: string | null; logisticsCompany?: string | null;
manualQueryUrl?: string | null; manualQueryUrl?: string | null;
/** 当次应付物流费(按瓶当量 × 承运商计价) */
logisticsFee?: number | null;
}; };
}; };
+67
View File
@@ -0,0 +1,67 @@
import type { MouseEvent, ReactNode } from 'react';
import type { ColumnType } from 'antd/es/table';
export const MIN_COL_WIDTH = 48;
export const MAX_COL_WIDTH = 960;
export function clampColWidth(n: number): number {
return Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, Math.round(n)));
}
export function beginColumnResize(
e: MouseEvent,
startWidth: number,
onMove: (width: number) => void,
onEnd: (width: number) => void,
) {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
let latest = clampColWidth(startWidth);
let moved = false;
document.body.classList.add('admin-col-resizing');
const onMouseMove = (ev: globalThis.MouseEvent) => {
moved = true;
latest = clampColWidth(startWidth + ev.clientX - startX);
onMove(latest);
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.classList.remove('admin-col-resizing');
if (moved) onEnd(latest);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
export function withResizeTitle<T>(
title: ColumnType<T>['title'],
onMouseDown: (e: MouseEvent) => void,
): ColumnType<T>['title'] {
const handle: ReactNode = (
<span
role="separator"
aria-orientation="vertical"
aria-label="拖动调整列宽"
className="admin-col-resize-handle"
onClick={(ev) => ev.stopPropagation()}
onMouseDown={onMouseDown}
/>
);
if (typeof title === 'function') {
return (props) => (
<>
{title(props)}
{handle}
</>
);
}
return (
<>
{title}
{handle}
</>
);
}
+6
View File
@@ -7,6 +7,12 @@ export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
MANUAL: '手动', MANUAL: '手动',
}; };
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
ON_SITE_PICKUP: '现场提货',
};
export const ORDER_STATUS_LABELS: Record<string, string> = { export const ORDER_STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款', PENDING_PAY: '待付款',
PENDING_SHIP: '待发货', PENDING_SHIP: '待发货',
+16
View File
@@ -8,3 +8,19 @@ export function downloadExcelCsv(csv: string, filename: string) {
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/** Download binary file from base64 payload returned by HQ export APIs. */
export function downloadBase64File(contentBase64: string, filename: string, mimeType: string) {
const binary = atob(contentBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
@@ -0,0 +1,97 @@
import type { ColumnType } from 'antd/es/table';
import type { HqListColumnKey, HqListColumnPref } from '@dukang/shared-types';
export const SERIAL_COLUMN_KEY = '__serial';
export const ACTIONS_COLUMN_KEY = '__actions';
export function columnKey<T>(col: ColumnType<T>, index: number): string {
if (col.key != null && String(col.key).length) return String(col.key);
const dataIndex = col.dataIndex;
if (typeof dataIndex === 'string' && dataIndex) return dataIndex;
if (Array.isArray(dataIndex) && dataIndex.length) return dataIndex.map(String).join('.');
if (typeof col.title === 'string' && col.title) return col.title;
return `col-${index}`;
}
export function isActionsColumn<T>(col: ColumnType<T>, key: string): boolean {
if (key === ACTIONS_COLUMN_KEY || key === 'actions' || key === 'action') return true;
return col.title === '操作';
}
export function mergeColumnOrder(defaultKeys: string[], pref?: HqListColumnPref): string[] {
const known = new Set(defaultKeys);
const ordered = (pref?.order ?? []).filter((k) => known.has(k));
for (const key of defaultKeys) {
if (ordered.includes(key)) continue;
const defaultIdx = defaultKeys.indexOf(key);
let insertAt = ordered.length;
for (let i = 0; i < ordered.length; i += 1) {
if (defaultKeys.indexOf(ordered[i]) > defaultIdx) {
insertAt = i;
break;
}
}
ordered.splice(insertAt, 0, key);
}
return ordered;
}
export function visibleColumnKeys(defaultKeys: string[], pref?: HqListColumnPref): string[] {
const hidden = new Set(pref?.hidden ?? []);
return mergeColumnOrder(defaultKeys, pref).filter((k) => !hidden.has(k));
}
export function applyColumnPrefs<T>(
columns: ColumnType<T>[],
pref: HqListColumnPref | undefined,
): ColumnType<T>[] {
const keyed = columns.map((col, i) => {
const key = columnKey(col, i);
return { col: { ...col, key }, key, actions: isActionsColumn(col, key) };
});
const configurable = keyed.filter((c) => !c.actions);
const actions = keyed.filter((c) => c.actions);
const defaultKeys = configurable.map((c) => c.key);
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
return [
...orderedKeys
.map((k) => {
const col = byKey.get(k);
if (!col) return null;
const w = pref?.widths?.[k];
return w != null ? { ...col, width: w } : col;
})
.filter((c): c is ColumnType<T> => !!c),
...actions.map((c) => {
const w = pref?.widths?.[c.key];
return w != null ? { ...c.col, width: w } : c.col;
}),
];
}
export type ListColumnSettingItem = {
key: string;
title: string;
visible: boolean;
};
export function settingItems<T>(
columns: ColumnType<T>[],
pref?: HqListColumnPref,
): ListColumnSettingItem[] {
const keyed = columns
.map((col, i) => ({ col, key: columnKey(col, i) }))
.filter(({ col, key }) => !isActionsColumn(col, key));
const hidden = new Set(pref?.hidden ?? []);
const order = mergeColumnOrder(keyed.map((c) => c.key), pref);
const byKey = new Map(keyed.map((c) => [c.key, c.col]));
return order.map((key) => {
const col = byKey.get(key);
const title = typeof col?.title === 'string' ? col.title : key;
return { key, title, visible: !hidden.has(key) };
});
}
export type { HqListColumnKey };
@@ -0,0 +1,175 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { Button, message } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import type { ColumnsType, ColumnType } from 'antd/es/table';
import type { HqListColumnKey } from '@dukang/shared-types';
import { AdminListColumnsModal } from '../components/AdminListColumnsModal';
import { useListColumnPrefs } from './ListColumnPrefsContext';
import {
ACTIONS_COLUMN_KEY,
SERIAL_COLUMN_KEY,
applyColumnPrefs,
columnKey,
settingItems,
type ListColumnSettingItem,
} from './list-column-prefs';
import { beginColumnResize, withResizeTitle } from './column-resize';
type Options = {
page?: number;
pageSize?: number;
};
export function useAdminListColumns<T>(
listKey: HqListColumnKey,
allColumns: ColumnsType<T>,
options: Options = {},
) {
const { page = 1, pageSize = 20 } = options;
const { prefs, save } = useListColumnPrefs();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [localWidths, setLocalWidths] = useState<Record<string, number>>({});
const pref = prefs[listKey];
const prefRef = useRef(pref);
prefRef.current = pref;
const localWidthsRef = useRef(localWidths);
localWidthsRef.current = localWidths;
const itemsRef = useRef<ListColumnSettingItem[]>([]);
const configured = useMemo(
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
[allColumns, pref],
);
const items = useMemo(
() => settingItems(allColumns as ColumnType<T>[], pref),
[allColumns, pref],
);
itemsRef.current = items;
const serialCol: ColumnType<T> = useMemo(
() => ({
key: SERIAL_COLUMN_KEY,
title: '序号',
width: 64,
fixed: 'left' as const,
render: (_: unknown, __: T, index: number) => (page - 1) * pageSize + index + 1,
}),
[page, pageSize],
);
const persistPref = useCallback(
async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
if (!next) {
setLocalWidths({});
await save(listKey, null);
return;
}
await save(listKey, next);
},
[listKey, save],
);
const persist = useCallback(
async (next: ListColumnSettingItem[] | null) => {
setSaving(true);
try {
if (!next) {
await persistPref(null);
} else {
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
await persistPref({
order: next.map((i) => i.key),
hidden: next.filter((i) => !i.visible).map((i) => i.key),
...(Object.keys(widths).length ? { widths } : {}),
});
}
setOpen(false);
message.success(next ? '列设置已保存' : '已恢复默认列');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
},
[persistPref],
);
const persistWidth = useCallback(
async (key: string, width: number) => {
const current = prefRef.current;
const prevW = current?.widths?.[key];
if (prevW === width) return;
const order = current?.order?.length ? current.order : itemsRef.current.map((i) => i.key);
const hidden = current?.hidden ?? [];
const widths = { ...(current?.widths ?? {}), ...localWidthsRef.current, [key]: width };
try {
await persistPref({ order, hidden, widths });
} catch (e) {
message.error(e instanceof Error ? e.message : '列宽保存失败');
}
},
[persistPref],
);
const columns = useMemo<ColumnsType<T>>(() => {
const merged: ColumnType<T>[] = [
serialCol,
...configured.map((col) =>
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
: col,
),
];
const widths = { ...(pref?.widths ?? {}), ...localWidths };
return merged.map((col, i) => {
const key = columnKey(col, i);
const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined);
const prevHeader = col.onHeaderCell;
return {
...col,
key,
width,
title: withResizeTitle(col.title, (e) => {
const th = (e.currentTarget as HTMLElement).closest('th');
const startWidth = width ?? th?.getBoundingClientRect().width ?? 120;
beginColumnResize(
e,
startWidth,
(next) => setLocalWidths((prev) => ({ ...prev, [key]: next })),
(next) => void persistWidth(key, next),
);
}),
onHeaderCell: (column) => {
const extra = typeof prevHeader === 'function' ? prevHeader(column) : {};
return {
...extra,
className: [extra.className, 'admin-th-resizable'].filter(Boolean).join(' '),
};
},
};
});
}, [configured, serialCol, pref?.widths, localWidths, persistWidth]);
const settingsButton = (
<span className="admin-list-settings-slot">
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
</Button>
</span>
);
const settingsModal = (
<AdminListColumnsModal
open={open}
items={items}
saving={saving}
onCancel={() => setOpen(false)}
onReset={() => void persist(null)}
onSave={(draft) => void persist(draft)}
/>
);
return { columns, settingsButton, settingsModal };
}
+139 -24
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -9,6 +10,7 @@ import {
Modal, Modal,
Popconfirm, Popconfirm,
Select, Select,
Space,
Table, Table,
Tag, Tag,
Typography, Typography,
@@ -17,9 +19,23 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types'; import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants'; import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/constants';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions'; import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CouponOrder = {
id?: string;
orderNo?: string;
productName?: string | null;
productSpec?: string | null;
quantity?: number | null;
saleUnit?: string | null;
deliveryType?: string | null;
payAmount?: number | string | null;
};
type Row = { type Row = {
id: string; id: string;
@@ -30,10 +46,43 @@ type Row = {
status: string; status: string;
sourceProduct: string; sourceProduct: string;
createdAt: string; createdAt: string;
user?: { userNo: string; phone: string | null }; user?: { id?: string; userNo: string; phone: string | null };
order?: { orderNo: string } | null; order?: CouponOrder | null;
}; };
function saleUnitLabel(unit?: string | null) {
if (unit === 'BOX') return '箱';
if (unit === 'BOTTLE') return '瓶';
return '';
}
function formatPayAmount(v?: number | string | null) {
if (v == null || v === '') return '';
const n = Number(v);
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '';
}
/** 来源:有订单时展示商品名、规格、数量、配送方式、金额;手动发放沿用 sourceProduct */
function formatCouponSource(row: { sourceProduct?: string; order?: CouponOrder | null }) {
const order = row.order;
if (!order?.orderNo && !order?.productName) {
return row.sourceProduct || '—';
}
const unit = saleUnitLabel(order.saleUnit);
const qty =
order.quantity != null ? `${order.quantity}${unit}` : '';
const delivery = DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '';
const amount = formatPayAmount(order.payAmount);
const parts = [
order.productName || row.sourceProduct,
order.productSpec,
qty,
delivery,
amount,
].filter((p) => p != null && String(p).trim() !== '');
return parts.join(' / ') || row.sourceProduct || '—';
}
type CouponRedeemRecord = { type CouponRedeemRecord = {
id: string; id: string;
redeemNo: string; redeemNo: string;
@@ -58,11 +107,10 @@ type CouponRedeemSummary = {
type CouponDetail = Row & { type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null; redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[]; redeemRecords?: CouponRedeemRecord[];
user?: { userNo?: string; phone?: string | null };
order?: { orderNo?: string } | null;
}; };
export default function BenefitCouponsPage() { export default function BenefitCouponsPage() {
const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>(); const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
@@ -99,21 +147,62 @@ export default function BenefitCouponsPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, {
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false }, title: '券号',
dataIndex: 'couponNo',
width: 200,
ellipsis: false,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '用户',
dataIndex: ['user', 'userNo'],
width: 120,
ellipsis: false,
render: (v: string | undefined, row) =>
row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{v}
</AdminPrimaryLink>
) : (
v || '—'
),
},
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' }, { title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
{ {
title: '订单', title: '订单',
dataIndex: ['order', 'orderNo'], dataIndex: ['order', 'orderNo'],
width: 180, width: 180,
ellipsis: false, ellipsis: false,
render: (v) => v || '—', render: (v: string | undefined) =>
v ? (
<AdminPrimaryLink onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}>
{v}
</AdminPrimaryLink>
) : (
'—'
),
}, },
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` }, { title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` }, { title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true }, {
title: '来源',
dataIndex: 'sourceProduct',
width: 360,
ellipsis: false,
render: (_: string, row) => formatCouponSource(row),
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
@@ -155,16 +244,20 @@ export default function BenefitCouponsPage() {
} }
} }
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
title="好客权益券"
</Typography.Title> settings={settingsButton}
<Button type="primary" onClick={() => setGrantOpen(true)}> actions={
<Button type="primary" onClick={() => setGrantOpen(true)}>
</Button>
</div> </Button>
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="couponNo" label="券号"> <Form.Item name="couponNo" label="券号">
@@ -190,7 +283,7 @@ export default function BenefitCouponsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -268,15 +361,37 @@ export default function BenefitCouponsPage() {
<> <>
<Descriptions column={1} bordered size="small"> <Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item> <Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="用户">
{detail.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(detail.user!.id) } })}
>
{detail.user?.userNo}
</AdminPrimaryLink>
) : (
(detail.user?.userNo ?? '—')
)}
</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item> <Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="关联订单">
{detail.order?.orderNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/orders?orderNo=${encodeURIComponent(detail.order!.orderNo!)}`)
}
>
{detail.order.orderNo}
</AdminPrimaryLink>
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[detail.status] || detail.status} {COUPON_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item> <Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
</Descriptions> </Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}> <Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
@@ -316,7 +431,7 @@ export default function BenefitCouponsPage() {
locale={{ emptyText: '暂无关联核销单' }} locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []} dataSource={detail.redeemRecords ?? []}
columns={[ columns={[
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true }, { title: '核销号', dataIndex: 'redeemNo' },
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
@@ -327,7 +442,7 @@ export default function BenefitCouponsPage() {
{ {
title: '门店', title: '门店',
dataIndex: ['store', 'name'], dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row: CouponRedeemRecord) => render: (v: string | undefined, row: CouponRedeemRecord) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—', v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
}, },
@@ -3,6 +3,9 @@ import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants'; import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type Row = { type Row = {
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string; id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
@@ -24,19 +27,22 @@ export default function BenefitLedgersPage() {
[filters], [filters],
); );
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> }, { title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false }, { title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false }, { title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` }, { title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 }, { title: '余额后', dataIndex: 'balanceAfter', width: 90 },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark' },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-ledgers', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> {settingsModal}
<AdminListHeader title="权益流水" settings={settingsButton} />
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item> <Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item> <Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
@@ -45,7 +51,7 @@ export default function BenefitLedgersPage() {
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }} <Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
</div> </div>
); );
+525
View File
@@ -0,0 +1,525 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { request } from '../lib/api';
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
export type BigScreenOrder = {
id: string;
orderNo: string;
payAmount: number;
items: string;
/** 展示用时间(付款成功时间) */
createdAt: string;
paidAt?: string;
userPhoneMasked: string | null;
};
type AmountTier = 1 | 2 | 3;
const POLL_MS = 3000;
const ROW_MS = 2500;
/** 不足此条数时重复填充,保证滚动连贯 */
const MIN_SCROLL_ROWS = 10;
const FX_DURATION_MS = 10_000;
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const CELEBRATE_SFX: Record<AmountTier, string> = {
1: sfxTier1,
2: sfxTier2,
3: sfxTier3,
};
/** 浏览器自动播放策略:需用户手势后才能出声 */
let audioUnlocked = false;
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
function unlockCelebrateAudio() {
if (audioUnlocked || !sharedAudio) return;
sharedAudio.muted = true;
sharedAudio.src = CELEBRATE_SFX[1];
void sharedAudio
.play()
.then(() => {
sharedAudio.pause();
sharedAudio.currentTime = 0;
sharedAudio.muted = false;
audioUnlocked = true;
})
.catch(() => {
/* 等待下次手势 */
});
}
function playCelebrateSfx(tier: AmountTier): () => void {
if (!sharedAudio) return () => undefined;
try {
sharedAudio.pause();
sharedAudio.currentTime = 0;
sharedAudio.src = CELEBRATE_SFX[tier];
sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
sharedAudio.muted = false;
void sharedAudio.play().catch(() => {
/* 未解锁时静默失败 */
});
} catch {
/* ignore */
}
return () => {
try {
sharedAudio.pause();
sharedAudio.currentTime = 0;
} catch {
/* ignore */
}
};
}
function pad(n: number) {
return String(n).padStart(2, '0');
}
function formatClock(d: Date) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatDateLine(d: Date) {
return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`;
}
function formatOrderTime(iso: string): string {
try {
return formatClock(new Date(iso));
} catch {
return iso;
}
}
// function formatAmount(n: number): string {
// return Number(n || 0).toLocaleString('zh-CN', {
// minimumFractionDigits: 0,
// maximumFractionDigits: 0,
// });
// }
function formatAmount(n: number): string {
const num = Number(n || 0);
// 先保留两位小数,再分割处理
const fixed = num.toFixed(2);
const [intStr, decStr] = fixed.split('.');
const intFormatted = Number(intStr).toLocaleString('zh-CN');
return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`;
}
function amountCssClass(payAmount: number, base: string): string {
const wide = Math.floor(Math.abs(Number(payAmount || 0))) >= 100_000;
return wide ? `${base} ${base}--6d` : base;
}
export function amountTier(payAmount: number): AmountTier {
if (payAmount >= 1000) return 3;
if (payAmount >= 500) return 2;
return 1;
}
function LiveClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return (
<div className="big-screen-hero">
<div className="big-screen-clock">{formatClock(now)}</div>
<div className="big-screen-date">{formatDateLine(now)}</div>
</div>
);
}
function OrderRow({
order,
latest,
}: {
order: BigScreenOrder;
latest?: boolean;
}) {
const tier = amountTier(order.payAmount);
return (
<div
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
>
{latest ? <span className="big-screen-row-mark" /> : null}
<span className={amountCssClass(order.payAmount, 'big-screen-amount')}>
¥ {formatAmount(order.payAmount)}
</span>
<span className="big-screen-items">{order.items || '—'}</span>
<span className="big-screen-time">{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
</div>
);
}
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
rot: number;
vr: number;
w: number;
h: number;
color: string;
life: number;
kind: 'rect' | 'ribbon' | 'spark';
rain?: boolean;
};
const TIER_COLORS: Record<AmountTier, string[]> = {
1: ['#e6f7ff', '#91d5ff', '#40a9ff', '#ffffff', '#69c0ff'],
2: ['#fff1b8', '#ffe58f', '#ffd666', '#fffbe6', '#ffe7ba'],
3: ['#ffd666', '#faad14', '#ffec3d', '#fff1b8', '#ff4d4f', '#ff7a45', '#ffffff'],
};
function spawnParticles(tier: AmountTier, w: number, h: number): Particle[] {
const colors = TIER_COLORS[tier];
const count = tier === 3 ? 180 : tier === 2 ? 110 : 70;
const out: Particle[] = [];
const cx = w / 2;
const cy = h * 0.42;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = (tier === 3 ? 8 : tier === 2 ? 6 : 4) * (0.4 + Math.random());
const kind: Particle['kind'] =
tier === 3 && Math.random() < 0.25 ? 'ribbon' : Math.random() < 0.2 ? 'spark' : 'rect';
out.push({
x: cx + (Math.random() - 0.5) * 80,
y: cy + (Math.random() - 0.5) * 40,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - (tier === 3 ? 6 : 3),
rot: Math.random() * 360,
vr: (Math.random() - 0.5) * 18,
w: kind === 'ribbon' ? 10 + Math.random() * 16 : kind === 'spark' ? 2 : 6 + Math.random() * 8,
h: kind === 'ribbon' ? 28 + Math.random() * 24 : kind === 'spark' ? 10 + Math.random() * 8 : 4 + Math.random() * 6,
color: colors[Math.floor(Math.random() * colors.length)],
life: 1,
kind,
});
}
if (tier >= 2) {
for (let i = 0; i < (tier === 3 ? 80 : 40); i++) {
out.push({
x: Math.random() * w,
y: -20 - Math.random() * 80,
vx: (Math.random() - 0.5) * 1.4,
vy: 3 + Math.random() * 5,
rot: Math.random() * 360,
vr: (Math.random() - 0.5) * 10,
w: 5 + Math.random() * 8,
h: 10 + Math.random() * 14,
color: colors[Math.floor(Math.random() * colors.length)],
life: 1,
kind: 'rect',
rain: true,
});
}
}
return out;
}
function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => void }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
const tier = amountTier(order.payAmount);
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
useEffect(() => {
const timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS);
return () => window.clearTimeout(timer);
}, [order.id]);
useEffect(() => {
const stop = playCelebrateSfx(tier);
return stop;
}, [order.id, tier]);
useEffect(() => {
if (tier !== 3) return;
const start = performance.now();
const dur = 900;
let raf = 0;
const tick = (now: number) => {
const p = Math.min(1, (now - start) / dur);
const eased = 1 - (1 - p) ** 3;
setDisplayAmount(Math.round(order.payAmount * eased));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [order.payAmount, order.id, tier]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
};
resize();
const particles = spawnParticles(tier, canvas.clientWidth, canvas.clientHeight);
let raf = 0;
let last = performance.now();
const started = performance.now();
const gravity = tier === 3 ? 0.18 : 0.14;
const loop = (now: number) => {
const dt = Math.min(32, now - last) / 16.6;
last = now;
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
for (const p of particles) {
p.vy += gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
p.life -= (tier === 3 ? 0.002 : 0.003) * dt;
if (
p.rain &&
(p.y > canvas.clientHeight + 30 || p.life <= 0) &&
now - started < FX_DURATION_MS - 400
) {
p.x = Math.random() * canvas.clientWidth;
p.y = -16 - Math.random() * 60;
p.vx = (Math.random() - 0.5) * 1.4;
p.vy = 3 + Math.random() * 5;
p.life = 1;
}
if (p.life <= 0) continue;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate((p.rot * Math.PI) / 180);
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
if (p.kind === 'spark') {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
const onResize = () => resize();
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
};
}, [order.id, tier]);
return (
<div className={`big-screen-fx big-screen-fx--t${tier}`} role="presentation">
<canvas ref={canvasRef} className="big-screen-fx-canvas" />
{tier === 3 ? <div className="big-screen-fx-shock" /> : null}
{tier === 3 ? <div className="big-screen-fx-shock big-screen-fx-shock--late" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
<div className={amountCssClass(displayAmount || order.payAmount, 'big-screen-fx-amount')}>
¥ {formatAmount(displayAmount)}
</div>
<div className="big-screen-fx-items">{order.items || '—'}</div>
<div className="big-screen-fx-meta">
<span>{order.userPhoneMasked || '—'}</span>
<span>{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
</div>
</div>
</div>
);
}
export default function BigScreenPage() {
const [items, setItems] = useState<BigScreenOrder[]>([]);
const [loadError, setLoadError] = useState('');
const [paused, setPaused] = useState(false);
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
const seenIdsRef = useRef<Set<string> | null>(null);
const celebratedIdsRef = useRef<Set<string>>(new Set());
const queueRef = useRef<BigScreenOrder[]>([]);
const celebratingRef = useRef(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const dayKeyRef = useRef('');
const [viewportH, setViewportH] = useState(0);
const playNext = useCallback(() => {
const next = queueRef.current.shift() ?? null;
celebratingRef.current = !!next;
setCelebrate(next);
setPaused(!!next);
}, []);
const enqueueNew = useCallback(
(fresh: BigScreenOrder[]) => {
const toCelebrate = fresh.filter((o) => !celebratedIdsRef.current.has(o.id));
if (!toCelebrate.length) return;
for (const o of toCelebrate) celebratedIdsRef.current.add(o.id);
const ranked = [...toCelebrate].sort((a, b) => {
const td = amountTier(b.payAmount) - amountTier(a.payAmount);
if (td !== 0) return td;
return +new Date(b.paidAt ?? b.createdAt) - +new Date(a.paidAt ?? a.createdAt);
});
queueRef.current.push(...ranked);
if (!celebratingRef.current) playNext();
},
[playNext],
);
const fetchData = useCallback(() => {
const now = new Date();
const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
if (dayKeyRef.current && dayKeyRef.current !== todayKey) {
seenIdsRef.current = null;
celebratedIdsRef.current = new Set();
queueRef.current = [];
celebratingRef.current = false;
setCelebrate(null);
setPaused(false);
}
dayKeyRef.current = todayKey;
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
.then((d) => {
const list = d.items ?? [];
setItems(list);
setLoadError('');
const seen = seenIdsRef.current;
if (!seen) {
seenIdsRef.current = new Set(list.map((o) => o.id));
for (const o of list) celebratedIdsRef.current.add(o.id);
return;
}
const fresh = list.filter((o) => !seen.has(o.id));
for (const o of fresh) seen.add(o.id);
enqueueNew(fresh);
})
.catch((e) => {
setLoadError(e instanceof Error ? e.message : '加载失败');
});
}, [enqueueNew]);
useEffect(() => {
void fetchData();
const id = setInterval(() => void fetchData(), POLL_MS);
return () => clearInterval(id);
}, [fetchData]);
useEffect(() => {
unlockCelebrateAudio();
const unlock = () => unlockCelebrateAudio();
window.addEventListener('pointerdown', unlock, { once: true });
window.addEventListener('keydown', unlock, { once: true });
return () => {
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock);
};
}, []);
useEffect(() => {
const html = document.documentElement;
const prevHtml = html.style.overflow;
const prevBody = document.body.style.overflow;
html.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
return () => {
html.style.overflow = prevHtml;
document.body.style.overflow = prevBody;
};
}, []);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => setViewportH(el.clientHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const unitItems = useMemo(() => {
if (!items.length) return [];
const rowH = 80;
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
const minCount = Math.max(MIN_SCROLL_ROWS, visible + 1);
const unit: BigScreenOrder[] = [];
while (unit.length < minCount) unit.push(...items);
return unit;
}, [items, viewportH]);
const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]);
const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS;
const rolling = items.length > 0 && unitItems.length > 0;
const latestId = items[0]?.id;
return (
<div className="big-screen-page">
<div className="big-screen-stars" aria-hidden />
<div className="big-screen-frame" aria-hidden>
<span className="big-screen-corner big-screen-corner--tl" />
<span className="big-screen-corner big-screen-corner--tr" />
<span className="big-screen-corner big-screen-corner--bl" />
<span className="big-screen-corner big-screen-corner--br" />
</div>
<header className="big-screen-header">
<div className="big-screen-brand">
<h1 className="big-screen-title"></h1>
<span className="big-screen-subtitle">· </span>
</div>
<div className="big-screen-live-wrap">
<span className="big-screen-live-label"></span>
<span className="big-screen-live">
<span className="big-screen-live-dot" />
LIVE
</span>
</div>
</header>
<LiveClock />
<div className="big-screen-list">
<div className="big-screen-list-body" ref={viewportRef}>
{items.length === 0 ? null : (
<div
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
>
{trackItems.map((o, idx) => (
<OrderRow
key={`${o.id}-${idx}`}
order={o}
latest={idx % items.length === 0 && o.id === latestId}
/>
))}
</div>
)}
</div>
</div>
{items.length === 0 ? (
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
) : null}
{celebrate ? (
<CelebrateFx
key={celebrate.id}
order={celebrate}
onDone={playNext}
/>
) : null}
</div>
);
}
+27 -13
View File
@@ -24,6 +24,10 @@ import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/con
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityPartnersPanel from '../components/CityPartnersPanel'; import CityPartnersPanel from '../components/CityPartnersPanel';
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader'; import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type WarehouseRow = { type WarehouseRow = {
id: string; id: string;
@@ -223,9 +227,16 @@ export default function CitiesPage() {
} }
}; };
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 }, { title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 }, {
title: '城市',
dataIndex: 'name',
width: 100,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '省份', dataIndex: 'province', width: 90 }, { title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 }, { title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
@@ -261,7 +272,7 @@ export default function CitiesPage() {
const warehouseColumns: ColumnsType<WarehouseRow> = [ const warehouseColumns: ColumnsType<WarehouseRow> = [
{ title: '仓库名', dataIndex: 'name' }, { title: '仓库名', dataIndex: 'name' },
{ title: '地址', dataIndex: 'address', ellipsis: true }, { title: '地址', dataIndex: 'address' },
{ title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 }, { title: '电话', dataIndex: 'contactPhone', width: 120 },
{ {
@@ -270,7 +281,7 @@ export default function CitiesPage() {
width: 100, width: 100,
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v, render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
}, },
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' }, { title: '合伙人', dataIndex: 'partnerCompanyName', render: (v) => v || '—' },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> }, { title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
{ {
title: '操作', title: '操作',
@@ -311,12 +322,16 @@ export default function CitiesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('cities', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <AdminListHeader
<Button type="primary" onClick={openCreateModal}></Button> title="城市"
</Space> settings={settingsButton}
actions={<Button type="primary" onClick={openCreateModal}></Button>}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item> <Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item> <Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
@@ -325,7 +340,7 @@ export default function CitiesPage() {
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} <Table scroll={{ x: 'max-content' }} rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
@@ -623,9 +638,9 @@ export default function CitiesPage() {
)} )}
columns={[ columns={[
{ title: '类型', dataIndex: 'kind', width: 80 }, { title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true }, { title: '名称', dataIndex: 'name' },
{ title: '手机号', dataIndex: 'phone', width: 120 }, { title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true }, { title: '归属', dataIndex: 'parent' },
]} ]}
/> />
</div> </div>
@@ -640,13 +655,12 @@ export default function CitiesPage() {
locale={{ emptyText: '无门店' }} locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores} dataSource={deletePreview.stores}
columns={[ columns={[
{ title: '门店名', dataIndex: 'name', ellipsis: true }, { title: '门店名', dataIndex: 'name' },
{ title: '电话', dataIndex: 'phone', width: 120 }, { title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 }, { title: '状态', dataIndex: 'status', width: 90 },
{ {
title: '合伙人', title: '合伙人',
width: 140, width: 140,
ellipsis: true,
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—', render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
}, },
]} ]}
+40 -28
View File
@@ -36,6 +36,10 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type SubRow = PartnerSubAccountRow; type SubRow = PartnerSubAccountRow;
@@ -261,22 +265,27 @@ export default function CityPartnersPage() {
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05)); setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 90 }, { title: '城市', dataIndex: 'cityName', width: 90 },
{ {
title: '区县', title: '区县',
dataIndex: 'districtCodes', dataIndex: 'districtCodes',
width: 160, width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) => render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
}, },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 }, {
title: '公司名',
dataIndex: 'companyName',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '主账号姓名', title: '主账号姓名',
dataIndex: 'name', dataIndex: 'name',
width: 120, width: 120,
ellipsis: true,
render: (v, row) => ( render: (v, row) => (
<Space size={4}> <Space size={4}>
<span>{v}</span> <span>{v}</span>
@@ -311,7 +320,6 @@ export default function CityPartnersPage() {
title: '管仓仓库', title: '管仓仓库',
dataIndex: 'managedWarehouseName', dataIndex: 'managedWarehouseName',
width: 110, width: 110,
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -338,30 +346,34 @@ export default function CityPartnersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
title="城市合伙人"
</Typography.Title> settings={settingsButton}
<Button actions={
type="primary" <Button
onClick={() => { type="primary"
createForm.resetFields(); onClick={() => {
createForm.setFieldsValue({ createForm.resetFields();
orderCommissionRate: 0, createForm.setFieldsValue({
redeemCommissionRate: 3, orderCommissionRate: 0,
scopeType: CityPartnerScopeType.CITY_WIDE, redeemCommissionRate: 3,
}); scopeType: CityPartnerScopeType.CITY_WIDE,
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); });
setCreateMaxRate(0.05); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateCityCode(undefined); setCreateMaxRate(0.05);
setCreateOpen(true); setCreateCityCode(undefined);
}} setCreateOpen(true);
> }}
>
</Button>
</Space> </Button>
}
/>
<Alert <Alert
type="info" type="info"
@@ -427,7 +439,7 @@ export default function CityPartnersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={toTableRows(data?.items ?? [])} dataSource={toTableRows(data?.items ?? [])}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
childrenColumnName="__noTreeChildren__" childrenColumnName="__noTreeChildren__"
expandable={{ expandable={{
expandedRowRender: (record) => ( expandedRowRender: (record) => (
+45 -26
View File
@@ -14,7 +14,6 @@ import {
Switch, Switch,
Table, Table,
Tag, Tag,
Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -30,6 +29,10 @@ import {
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -70,6 +73,7 @@ function FulfillmentFields({
onModeChange: (mode: WarehouseFulfillmentMode) => void; onModeChange: (mode: WarehouseFulfillmentMode) => void;
}) { }) {
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO; const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
return ( return (
<> <>
<Form.Item <Form.Item
@@ -219,7 +223,7 @@ export default function CityWarehousesPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '城市', title: '城市',
width: 100, width: 100,
@@ -229,8 +233,15 @@ export default function CityWarehousesPage() {
</span> </span>
), ),
}, },
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true }, {
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true }, title: '仓库',
dataIndex: 'name',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '地址', dataIndex: 'address', width: 180 },
{ title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 }, { title: '电话', dataIndex: 'contactPhone', width: 120 },
{ {
@@ -242,7 +253,6 @@ export default function CityWarehousesPage() {
title: '合伙人', title: '合伙人',
dataIndex: 'partnerCompanyName', dataIndex: 'partnerCompanyName',
width: 120, width: 120,
ellipsis: true,
render: (v, row) => render: (v, row) =>
v && row.partnerAccountId ? ( v && row.partnerAccountId ? (
<Link to="/city-partners">{v}</Link> <Link to="/city-partners">{v}</Link>
@@ -298,28 +308,37 @@ export default function CityWarehousesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-warehouses', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <AdminListHeader
<Button title="仓库"
type="primary" settings={settingsButton}
onClick={() => { actions={
createForm.resetFields(); <Button
setCreateManagerType(WarehouseManagerType.HQ); type="primary"
setCreateCityId(undefined); onClick={() => {
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL); createForm.resetFields();
createForm.setFieldsValue({ setCreateManagerType(WarehouseManagerType.HQ);
managerType: WarehouseManagerType.HQ, setCreateCityId(undefined);
status: WarehouseStatus.ACTIVE, setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
fulfillmentMode: WarehouseFulfillmentMode.MANUAL, createForm.setFieldsValue({
}); managerType: WarehouseManagerType.HQ,
setCreateOpen(true); status: WarehouseStatus.ACTIVE,
}} fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
> });
setCreateOpen(true);
</Button> }}
</Space> >
</Button>
}
/>
<Alert <Alert
type="info" type="info"
@@ -367,7 +386,7 @@ export default function CityWarehousesPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1400 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+322 -158
View File
@@ -64,6 +64,20 @@ export default function DashboardPage() {
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
const [deploying, setDeploying] = useState(false); const [deploying, setDeploying] = useState(false);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
const keys = profile?.permissionKeys ?? [];
const has = (k: string) => keys.includes(k);
const cityScoped = !!profile?.cityScoped;
const canUsers = has('users');
const canOrders = has('orders');
const canStores = has('stores');
const canStoreAudits = has('store_audits');
const canPartners = has('partners');
const canPromo = has('promo_codes');
const canFinance = has('finance');
const canBenefit = has('benefit');
const canDeliveries = has('deliveries');
const canTickets = has('tickets');
const permsReady = profile !== null;
const [filterForm] = Form.useForm<{ const [filterForm] = Form.useForm<{
range: [Dayjs, Dayjs]; range: [Dayjs, Dayjs];
@@ -115,18 +129,26 @@ export default function DashboardPage() {
.catch(() => { .catch(() => {
setVersionLoading(false); setVersionLoading(false);
}); });
}, [loadVersion]);
useEffect(() => {
if (!profile) return;
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? [])) .then((res) => setCities(res.items ?? []))
.catch(() => setCities([])); .catch(() => setCities([]));
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) if ((profile.permissionKeys ?? []).includes('promo_codes')) {
.then((res) => setPromos(res.items ?? [])) void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.catch(() => setPromos([])); .then((res) => setPromos(res.items ?? []))
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>( .catch(() => setPromos([]));
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`, }
) if ((profile.permissionKeys ?? []).includes('partners')) {
.then((res) => setPartners(res.items ?? [])) void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
.catch(() => setPartners([])); `/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
}, [loadVersion]); )
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}
}, [profile]);
useEffect(() => { useEffect(() => {
void loadAnalytics(filters); void loadAnalytics(filters);
@@ -206,9 +228,19 @@ export default function DashboardPage() {
const byDateOption = useMemo<EChartsOption>(() => { const byDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? []; const rows = analytics?.byDate ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('新增用户');
series.push({ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) });
}
if (canOrders) {
legend.push('订单数');
series.push({ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) });
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['新增用户', '订单数'] }, legend: { data: legend },
grid: { left: 40, right: 20, top: 40, bottom: 40 }, grid: { left: 40, right: 20, top: 40, bottom: 40 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -216,18 +248,25 @@ export default function DashboardPage() {
axisLabel: { rotate: rows.length > 14 ? 45 : 0 }, axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
}, },
yAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'value', minInterval: 1 },
series: [ series,
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
],
}; };
}, [analytics]); }, [analytics, canUsers, canOrders]);
const byCityOption = useMemo<EChartsOption>(() => { const byCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? []; const rows = analytics?.byCity ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('用户');
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
}
if (canOrders) {
legend.push('订单');
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] }, legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 48 }, grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -235,18 +274,25 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
}, },
yAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'value', minInterval: 1 },
series: [ series,
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
}; };
}, [analytics]); }, [analytics, canUsers, canOrders]);
const byPromoOption = useMemo<EChartsOption>(() => { const byPromoOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPromo ?? []; const rows = analytics?.byPromo ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('用户');
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
}
if (canOrders) {
legend.push('订单');
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] }, legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 64 }, grid: { left: 48, right: 20, top: 40, bottom: 64 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -254,18 +300,36 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 }, axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
}, },
yAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'value', minInterval: 1 },
series: [ series,
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
}; };
}, [analytics]); }, [analytics, canUsers, canOrders]);
const opsByDateOption = useMemo<EChartsOption>(() => { const opsByDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? []; const rows = analytics?.byDate ?? [];
const legend: string[] = [];
const series: NonNullable<EChartsOption['series']> = [];
if (canPartners) {
legend.push('新增合伙人');
series.push({ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) });
}
if (canStores) {
legend.push('新签门店');
series.push({ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) });
}
if (canBenefit) {
legend.push('核销笔数', '核销金额');
series.push({ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) });
series.push({
name: '核销金额',
type: 'line',
smooth: true,
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
});
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] }, legend: { data: legend },
grid: { left: 48, right: 48, top: 48, bottom: 40 }, grid: { left: 48, right: 48, top: 48, bottom: 40 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -276,26 +340,29 @@ export default function DashboardPage() {
{ type: 'value', name: '数量', minInterval: 1 }, { type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额', minInterval: 1 }, { type: 'value', name: '金额', minInterval: 1 },
], ],
series: [ series,
{ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) },
{ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) },
{ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) },
{
name: '核销金额',
type: 'line',
smooth: true,
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
},
],
}; };
}, [analytics]); }, [analytics, canPartners, canStores, canBenefit]);
const opsByCityOption = useMemo<EChartsOption>(() => { const opsByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? []; const rows = analytics?.byCity ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canPartners) {
legend.push('合伙人');
series.push({ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 });
}
if (canStores) {
legend.push('门店');
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
}
if (canBenefit) {
legend.push('核销笔数');
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['合伙人', '门店', '核销笔数'] }, legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 48 }, grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -303,19 +370,31 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
}, },
yAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'value', minInterval: 1 },
series: [ series,
{ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 },
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
],
}; };
}, [analytics]); }, [analytics, canPartners, canStores, canBenefit]);
const opsByPartnerOption = useMemo<EChartsOption>(() => { const opsByPartnerOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPartner ?? []; const rows = analytics?.byPartner ?? [];
const legend: string[] = [];
const series: NonNullable<EChartsOption['series']> = [];
if (canStores) {
legend.push('门店');
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
}
if (canBenefit) {
legend.push('核销笔数', '核销金额');
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
series.push({
name: '核销金额',
type: 'line',
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
});
}
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['门店', '核销笔数', '核销金额'] }, legend: { data: legend },
grid: { left: 48, right: 48, top: 40, bottom: 64 }, grid: { left: 48, right: 48, top: 40, bottom: 64 },
xAxis: { xAxis: {
type: 'category', type: 'category',
@@ -326,18 +405,9 @@ export default function DashboardPage() {
{ type: 'value', name: '数量', minInterval: 1 }, { type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额' }, { type: 'value', name: '金额' },
], ],
series: [ series,
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
{
name: '核销金额',
type: 'line',
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
},
],
}; };
}, [analytics]); }, [analytics, canStores, canBenefit]);
const redeemByCityOption = useMemo<EChartsOption>(() => { const redeemByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? []; const rows = analytics?.byCity ?? [];
@@ -368,7 +438,9 @@ export default function DashboardPage() {
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}>
{cityScoped ? '数据概览(负责城市)' : '数据概览'}
</Typography.Title>
{isSuperAdmin ? ( {isSuperAdmin ? (
<Card <Card
@@ -411,46 +483,94 @@ export default function DashboardPage() {
) : null} ) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}> {!permsReady ? (
<Card loading={loading}> <Col xs={24} sm={12} lg={6}>
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} /> <Card loading />
</Card> </Col>
</Col> ) : null}
<Col xs={24} sm={12} lg={6}> {permsReady && canUsers ? (
<Card loading={loading}> <Col xs={24} sm={12} lg={6}>
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} /> <Card loading={loading}>
</Card> <Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
</Col> </Card>
<Col xs={24} sm={12} lg={6}> </Col>
<Card loading={loading}> ) : null}
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} /> {permsReady && canUsers ? (
</Card> <Col xs={24} sm={12} lg={6}>
</Col> <Card loading={loading}>
<Col xs={24} sm={12} lg={6}> <Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
<Card loading={loading}> </Card>
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} /> </Col>
</Card> ) : null}
</Col> {permsReady && canUsers ? (
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<Card loading={loading}> <Card loading={loading}>
<Statistic title="门店" value={stats?.storesTotal ?? 0} /> <Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={6}> ) : null}
<Card loading={loading}> {permsReady && canOrders ? (
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} /> <Col xs={24} sm={12} lg={6}>
</Card> <Card loading={loading}>
</Col> <Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
<Col xs={24} sm={12} lg={6}> </Card>
<Card loading={loading}> </Col>
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} /> ) : null}
</Card> {permsReady && canStores ? (
</Col> <Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={6}> <Card loading={loading}>
<Card loading={loading}> <Statistic title="门店" value={stats?.storesTotal ?? 0} />
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} /> </Card>
</Card> </Col>
</Col> ) : null}
{permsReady && canStores ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading} extra={<Link to="/stores?auditStatus=PENDING"></Link>}>
<Statistic
title="待入驻审核"
value={stats?.pendingStoreOnboard ?? 0}
valueStyle={{ color: (stats?.pendingStoreOnboard ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
</Card>
</Col>
) : null}
{permsReady && canStoreAudits ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading} extra={<Link to="/store-package-audits"></Link>}>
<Statistic
title="待套餐/信息审核"
value={(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0)}
valueStyle={{
color:
(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0) > 0
? '#fa8c16'
: undefined,
}}
/>
</Card>
</Col>
) : null}
{permsReady && canPartners ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
</Card>
</Col>
) : null}
{permsReady && canBenefit ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
</Card>
</Col>
) : null}
{permsReady && canDeliveries ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
</Card>
</Col>
) : null}
</Row> </Row>
<Card <Card
@@ -478,62 +598,73 @@ export default function DashboardPage() {
<Form.Item name="cityId" label="城市"> <Form.Item name="cityId" label="城市">
<Select <Select
allowClear allowClear
placeholder="全部开城" placeholder={cityScoped ? '全部负责城市' : '全部开城'}
style={{ width: 160 }} style={{ width: 160 }}
options={[ options={[
{ value: 'none', label: '未选城' }, ...(cityScoped ? [] : [{ value: 'none', label: '未选城' }]),
...cities.map((c) => ({ value: c.id, label: c.name })), ...cities.map((c) => ({ value: c.id, label: c.name })),
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="promoCodeId" label="推广码"> {canPromo ? (
<Select <Form.Item name="promoCodeId" label="推广码">
allowClear <Select
placeholder="全部来源" allowClear
style={{ width: 200 }} placeholder="全部来源"
options={[ style={{ width: 200 }}
{ value: 'none', label: '自然量 / 无推广码' }, options={[
...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })), { value: 'none', label: '自然量 / 无推广码' },
]} ...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })),
/> ]}
</Form.Item> />
<Form.Item name="partnerAccountId" label="合伙人"> </Form.Item>
<Select ) : null}
allowClear {canPartners ? (
placeholder="全部合伙人" <Form.Item name="partnerAccountId" label="合伙人">
style={{ width: 200 }} <Select
options={partners.map((p) => ({ allowClear
value: p.id, placeholder="全部合伙人"
label: p.companyName || p.name, style={{ width: 200 }}
}))} options={partners.map((p) => ({
/> value: p.id,
</Form.Item> label: p.companyName || p.name,
}))}
/>
</Form.Item>
) : null}
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
</Form.Item> </Form.Item>
</Form> </Form>
</Card> </Card>
{canUsers || canOrders ? (
<Card <Card
title="用户 / 订单统计" title={canUsers && canOrders ? '用户 / 订单统计' : canUsers ? '用户统计' : '订单统计'}
style={{ marginBottom: 24 }} style={{ marginBottom: 24 }}
extra={ extra={
<Space> <Space>
<Link to={`/orders?${ordersDrillQs}`}></Link> {canOrders ? <Link to={`/orders?${ordersDrillQs}`}></Link> : null}
<Link to="/users"></Link> {canUsers ? <Link to="/users"></Link> : null}
</Space> </Space>
} }
> >
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}> {canUsers ? (
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} /> <Col xs={24} sm={8}>
</Col> <Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
<Col xs={24} sm={8}> </Col>
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} /> ) : null}
</Col> {canOrders ? (
<Col xs={24} sm={8}> <Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} /> <Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
</Col> </Col>
) : null}
{canOrders ? (
<Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
</Col>
) : null}
</Row> </Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@@ -542,7 +673,7 @@ export default function DashboardPage() {
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge /> <ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={12}> <Col xs={24} lg={canPromo ? 12 : 24}>
<Card <Card
type="inner" type="inner"
title="按城市(点击柱联动筛选)" title="按城市(点击柱联动筛选)"
@@ -562,6 +693,7 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
{canPromo ? (
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card <Card
type="inner" type="inner"
@@ -584,42 +716,53 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
</Row> </Row>
</Card> </Card>
) : null}
{canPartners || canStores || canBenefit ? (
<Card <Card
title="合伙人 / 门店 / 核销统计" title={[canPartners && '合伙人', canStores && '门店', canBenefit && '核销'].filter(Boolean).join(' / ') + '统计'}
style={{ marginBottom: 24 }} style={{ marginBottom: 24 }}
extra={ extra={
<Space> <Space>
<Link to="/city-partners"></Link> {canPartners ? <Link to="/city-partners"></Link> : null}
<Link to="/stores"></Link> {canStores ? <Link to="/stores"></Link> : null}
<Link to="/redeem-records"></Link> {canBenefit ? <Link to="/redeem-records"></Link> : null}
</Space> </Space>
} }
> >
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={6}> {canPartners ? (
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} /> <Col xs={24} sm={6}>
</Col> <Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
<Col xs={24} sm={6}> </Col>
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} /> ) : null}
</Col> {canStores ? (
<Col xs={24} sm={6}> <Col xs={24} sm={6}>
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} /> <Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
</Col> </Col>
<Col xs={24} sm={6}> ) : null}
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} /> {canBenefit ? (
</Col> <Col xs={24} sm={6}>
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
</Col>
) : null}
{canBenefit ? (
<Col xs={24} sm={6}>
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
</Col>
) : null}
</Row> </Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24}> <Col xs={24}>
<Card type="inner" title="按日趋势(合伙人 / 门店 / 核销)" loading={analyticsLoading} size="small"> <Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge /> <ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={12}> <Col xs={24} lg={canBenefit ? 12 : 24}>
<Card <Card
type="inner" type="inner"
title="按城市(点击柱联动筛选)" title="按城市(点击柱联动筛选)"
@@ -639,6 +782,7 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
{canBenefit ? (
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card <Card
type="inner" type="inner"
@@ -659,6 +803,8 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
{canPartners && (canStores || canBenefit) ? (
<Col xs={24}> <Col xs={24}>
<Card <Card
type="inner" type="inner"
@@ -681,10 +827,14 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
</Row> </Row>
</Card> </Card>
) : null}
{canFinance || canTickets ? (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{canFinance ? (
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} lg={8}>
<Card <Card
loading={loading} loading={loading}
@@ -704,6 +854,8 @@ export default function DashboardPage() {
</Typography.Text> </Typography.Text>
</Card> </Card>
</Col> </Col>
) : null}
{canFinance ? (
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} lg={8}>
<Card <Card
loading={loading} loading={loading}
@@ -717,6 +869,8 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
{canFinance ? (
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} lg={8}>
<Card <Card
loading={loading} loading={loading}
@@ -737,20 +891,28 @@ export default function DashboardPage() {
</Typography.Text> </Typography.Text>
</Card> </Card>
</Col> </Col>
) : null}
{canTickets ? (
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单"> <Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" /> <Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card> </Card>
</Col> </Col>
) : null}
</Row> </Row>
) : null}
{canUsers || canOrders ? (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} lg={12}> {canUsers ? (
<Col xs={24} lg={canOrders ? 12 : 24}>
<Card title="已合并访客账号" loading={loading}> <Card title="已合并访客账号" loading={loading}>
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" /> <Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={12}> ) : null}
{canOrders ? (
<Col xs={24} lg={canUsers ? 12 : 24}>
<Card title="订单状态分布" loading={loading}> <Card title="订单状态分布" loading={loading}>
<Table <Table
size="small" size="small"
@@ -768,7 +930,9 @@ export default function DashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
) : null}
</Row> </Row>
) : null}
</div> </div>
); );
} }
+232 -26
View File
@@ -1,12 +1,43 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message, Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import OrderTrackDrawer from '../components/OrderTrackDrawer'; import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import {
DELIVERY_PROVIDER_LABELS,
DELIVERY_TYPE_LABELS,
ORDER_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type DeliveryOrder = {
id: string;
orderNo: string;
status: string;
deliveryType: string;
productName: string;
productSpec?: string | null;
barcode69?: string | null;
quantity: number;
saleUnit?: string;
bottlesPerUnit?: number;
payAmount?: number;
receiverName: string;
receiverPhone: string;
receiverAddress?: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
imageResource?: { url: string } | null;
};
type Row = { type Row = {
id: string; id: string;
@@ -15,17 +46,34 @@ type Row = {
trackingNo: string | null; trackingNo: string | null;
providerOrderNo: string | null; providerOrderNo: string | null;
updatedAt: string; updatedAt: string;
order?: { logisticsFee?: number | null;
id: string; order?: DeliveryOrder;
orderNo: string;
status: string;
receiverName: string;
receiverPhone: string;
deliveryType: string;
};
}; };
function formatQty(order?: DeliveryOrder | null) {
if (!order || order.quantity == null) return '—';
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
const bottles =
order.saleUnit === 'BOX' && order.bottlesPerUnit && order.bottlesPerUnit > 1
? `${order.quantity * order.bottlesPerUnit}瓶)`
: '';
return `${order.quantity}${unit}${bottles}`;
}
function formatAddress(order?: DeliveryOrder | null) {
if (!order) return '—';
if (order.deliveryType === 'ON_SITE_PICKUP') return '现场取货';
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
.filter(Boolean)
.join('');
const detail = (order.receiverAddress || '').trim();
if (!region) return detail || '—';
if (!detail || detail.startsWith(region)) return detail || region;
return `${region}${detail}`;
}
export default function DeliveriesPage() { export default function DeliveriesPage() {
const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
@@ -59,42 +107,148 @@ export default function DeliveriesPage() {
setTrackOpen(true); setTrackOpen(true);
} }
const columns: ColumnsType<Row> = [ async function openDetail(row: Row) {
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 }, const d = await request<Row>(`/admin/deliveries/${row.id}`);
{ title: 'provider', dataIndex: 'provider', width: 90 }, setDetail(d);
editForm.setFieldsValue({
provider: d.provider,
trackingNo: d.trackingNo,
providerOrderNo: d.providerOrderNo,
});
setDrawerOpen(true);
}
const baseColumns: ColumnsType<Row> = [
{
title: '订单号',
dataIndex: ['order', 'orderNo'],
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
),
},
{
title: '用户',
key: 'user',
width: 140,
render: (_, row) => {
const user = row.order?.user;
if (!user) return '—';
const label = user.userNo || user.phone || '—';
return (
<div>
{user.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(user.id) } })}
>
{label}
</AdminPrimaryLink>
) : (
label
)}
{user.phone && user.userNo ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{user.phone}
</Typography.Text>
) : null}
</div>
);
},
},
{
title: '商品',
key: 'product',
width: 200,
render: (_, row) => {
const order = row.order;
if (!order?.productName) return '—';
return (
<div>
<span>{order.productName}</span>
{order.productSpec ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{order.productSpec}
</Typography.Text>
) : null}
</div>
);
},
},
{
title: '数量',
key: 'quantity',
width: 90,
render: (_, row) => formatQty(row.order),
},
{
title: '配送方式',
dataIndex: ['order', 'deliveryType'],
width: 90,
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
},
{
title: '承运商',
dataIndex: 'provider',
width: 90,
render: (v: string) => DELIVERY_PROVIDER_LABELS[v] || v || '—',
},
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' }, { title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
{
title: '运费',
dataIndex: 'logisticsFee',
width: 90,
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
},
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' }, { title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s }, {
title: '订单状态',
dataIndex: ['order', 'status'],
width: 100,
render: (s) => ORDER_STATUS_LABELS[s] || s,
},
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 }, { title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
{
title: '收货电话',
dataIndex: ['order', 'receiverPhone'],
width: 120,
render: (v) => v || '—',
},
{
title: '配送地址',
key: 'address',
width: 280,
ellipsis: true,
render: (_, row) => formatAddress(row.order),
},
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime }, { title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 140, title: '操作', width: 140,
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" onClick={() => openTrack(row)}></Button> <Button type="link" size="small" onClick={() => openTrack(row)}></Button>
<Button type="link" size="small" onClick={async () => { <Button type="link" size="small" onClick={() => void openDetail(row)}></Button>
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> </Space>
), ),
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
const order = detail?.order;
return ( return (
<div> <div>
<Typography.Title level={4}>/</Typography.Title> {settingsModal}
<AdminListHeader title="快递/配送单" settings={settingsButton} />
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item> <Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item> <Form.Item name="provider" label="承运商"><Input allowClear placeholder="XFX" /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item> <Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }} <Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="配送单编辑" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={ extra={
<Space> <Space>
<Button onClick={() => setDrawerOpen(false)}></Button> <Button onClick={() => setDrawerOpen(false)}></Button>
@@ -111,11 +265,63 @@ export default function DeliveriesPage() {
{detail && ( {detail && (
<> <>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item> <Descriptions.Item label="订单">{order?.orderNo || '—'}</Descriptions.Item>
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item> <Descriptions.Item label="用户">
{order?.user ? (
<Space size={8} wrap>
{order.user.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(order.user!.id) } })}
>
{order.user.userNo}
</AdminPrimaryLink>
) : (
order.user.userNo || '—'
)}
<Typography.Text type="secondary">
{[order.user.nickname, order.user.phone].filter(Boolean).join(' / ') || ''}
</Typography.Text>
</Space>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="商品">
<Space align="start">
{order?.imageResource?.url ? (
<img
src={order.imageResource.url}
alt=""
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4 }}
/>
) : null}
<span>
{order?.productName || '—'}
{order?.productSpec ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{order.productSpec}
</Typography.Text>
) : null}
{order?.barcode69 ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{order.barcode69}
</Typography.Text>
) : null}
</span>
</Space>
</Descriptions.Item>
<Descriptions.Item label="数量">{formatQty(order)}</Descriptions.Item>
<Descriptions.Item label="配送方式">
{DELIVERY_TYPE_LABELS[order?.deliveryType ?? ''] || order?.deliveryType || '—'}
</Descriptions.Item>
<Descriptions.Item label="收货人">
{[order?.receiverName, order?.receiverPhone].filter(Boolean).join(' ') || '—'}
</Descriptions.Item>
<Descriptions.Item label="配送地址">{formatAddress(order)}</Descriptions.Item>
<Descriptions.Item label="运费">
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
</Descriptions.Item>
</Descriptions> </Descriptions>
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="provider" label="承运商" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item> <Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item> <Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form> </Form>
+135 -23
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { import {
Button, Button,
Form, Form,
Image,
Input, Input,
Modal, Modal,
Popconfirm, Popconfirm,
@@ -18,6 +19,7 @@ import {
DEV_PLAN_TASK_TYPE_LABELS, DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS, SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto, type DevPlanTaskDto,
type DevPlanTaskExportFormat,
type DevPlanTaskStatusDto, type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto, type DevPlanTaskTypeDto,
type DevPlanVersionDto, type DevPlanVersionDto,
@@ -25,7 +27,13 @@ import {
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({ const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
value: v, value: v,
@@ -56,6 +64,8 @@ export default function DevPlanTasksPage() {
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null); const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false); const [dispatching, setDispatching] = useState(false);
const [exportFormat, setExportFormat] = useState<DevPlanTaskExportFormat>('xlsx');
const [exporting, setExporting] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [dispatchForm] = Form.useForm<{ supplement?: string }>(); const [dispatchForm] = Form.useForm<{ supplement?: string }>();
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>(); const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
@@ -88,24 +98,30 @@ export default function DevPlanTasksPage() {
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined }); form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined, attachmentUrls: [''] });
setModalOpen(true); setModalOpen(true);
} }
function openEdit(row: DevPlanTaskDto) { function openEdit(row: DevPlanTaskDto) {
setEditing(row); setEditing(row);
form.setFieldsValue({ content: row.content, type: row.type, status: row.status }); form.setFieldsValue({
content: row.content,
type: row.type,
status: row.status,
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
});
setModalOpen(true); setModalOpen(true);
} }
async function save() { async function save() {
const values = await form.validateFields(); const values = await form.validateFields();
const attachmentUrls = (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean);
setSaving(true); setSaving(true);
try { try {
if (editing) { if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, { await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(values), body: JSON.stringify({ ...values, attachmentUrls }),
}); });
message.success('已更新'); message.success('已更新');
} else { } else {
@@ -115,6 +131,7 @@ export default function DevPlanTasksPage() {
content: values.content, content: values.content,
type: values.type, type: values.type,
supportTicketId: values.supportTicketId || undefined, supportTicketId: values.supportTicketId || undefined,
attachmentUrls,
}), }),
}); });
message.success('已创建'); message.success('已创建');
@@ -199,8 +216,43 @@ export default function DevPlanTasksPage() {
} }
} }
const columns: ColumnsType<DevPlanTaskDto> = [ async function exportTasks(scope: 'filter' | 'selected') {
{ title: '任务号', dataIndex: 'taskNo', width: 160 }, setExporting(true);
try {
const result = await request<{
filename: string;
mimeType: string;
contentBase64: string;
count: number;
}>('/admin/dev-plan/tasks/export', {
method: 'POST',
body: JSON.stringify({
scope,
format: exportFormat,
ids: scope === 'selected' ? selectedRowKeys : undefined,
status: filters.status || undefined,
type: filters.type || undefined,
keyword: filters.keyword || undefined,
}),
});
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条任务`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(false);
}
}
const baseColumns: ColumnsType<DevPlanTaskDto> = [
{
title: '任务号',
dataIndex: 'taskNo',
width: 160,
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
@@ -215,7 +267,23 @@ export default function DevPlanTasksPage() {
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag> <Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
), ),
}, },
{ title: '内容', dataIndex: 'content', ellipsis: true }, { title: '内容', dataIndex: 'content' },
{
title: '附件',
width: 100,
render: (_, row) =>
row.attachmentUrls?.length ? (
<Image.PreviewGroup>
<Space size={4}>
{row.attachmentUrls.slice(0, 3).map((url) => (
<Image key={url} src={url} width={32} height={32} style={{ objectFit: 'cover' }} />
))}
</Space>
</Image.PreviewGroup>
) : (
'—'
),
},
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' }, { title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
{ title: '创建人', dataIndex: 'creatorName', width: 90 }, { title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -237,24 +305,45 @@ export default function DevPlanTasksPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
· title="开发计划 · 任务列表"
</Typography.Title> settings={settingsButton}
<Space> actions={
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}> <>
<Select
</Button> value={exportFormat}
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}> style={{ width: 120 }}
onChange={setExportFormat}
</Button> options={[
<Button type="primary" onClick={openCreate}> { value: 'markdown', label: 'Markdown' },
{ value: 'docx', label: 'Word' },
</Button> { value: 'xlsx', label: 'Excel' },
</Space> { value: 'pdf', label: 'PDF' },
</div> ]}
/>
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
</Button>
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
</Button>
<Button type="primary" onClick={openCreate}>
</Button>
</>
}
/>
<Form <Form
layout="inline" layout="inline"
@@ -287,7 +376,7 @@ export default function DevPlanTasksPage() {
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
}} }}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -333,6 +422,29 @@ export default function DevPlanTasksPage() {
/> />
</Form.Item> </Form.Item>
)} )}
<Form.Item label="附件图片">
<Form.List name="attachmentUrls">
{(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="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 ? (
<Button type="link" danger onClick={() => remove(field.name)}>
</Button>
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block>
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
@@ -9,7 +9,6 @@ import {
Space, Space,
Table, Table,
Tag, Tag,
Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -22,6 +21,10 @@ import {
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map( const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
@@ -119,8 +122,10 @@ export default function DevPlanVersionsPage() {
} }
} }
const columns: ColumnsType<DevPlanVersionDto> = [ const baseColumns: ColumnsType<DevPlanVersionDto> = [
{ title: '版本号', dataIndex: 'versionNo', width: 120 }, { title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
) },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -129,7 +134,7 @@ export default function DevPlanVersionsPage() {
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag> <Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
), ),
}, },
{ title: '内容', dataIndex: 'content', ellipsis: true, render: (v) => v || '—' }, { title: '内容', dataIndex: 'content', render: (v) => v || '—' },
{ title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') }, { title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
{ title: '开发完成', dataIndex: 'devCompletedAt', 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: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
@@ -158,16 +163,20 @@ export default function DevPlanVersionsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-versions', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
· title="开发计划 · 版本列表"
</Typography.Title> settings={settingsButton}
<Button type="primary" onClick={openCreate}> actions={
<Button type="primary" onClick={openCreate}>
</Button>
</div> </Button>
}
/>
<Form <Form
layout="inline" layout="inline"
@@ -190,7 +199,7 @@ export default function DevPlanVersionsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+24 -3
View File
@@ -4,6 +4,9 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -55,9 +58,22 @@ export default function DomainEventsPage() {
load(); load();
}, [load]); }, [load]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) }, { title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> }, {
title: '类型',
dataIndex: 'eventType',
width: 130,
render: (v, r) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
}}
>
<Tag>{v}</Tag>
</AdminPrimaryLink>
),
},
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` }, { title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
{ title: '状态', dataIndex: 'status', width: 100 }, { title: '状态', dataIndex: 'status', width: 100 },
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' }, { title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
@@ -76,8 +92,13 @@ export default function DomainEventsPage() {
}, },
]; ];
const page = Number(params.get('page') ?? 1);
const pageSize = data?.pageSize ?? 20;
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-domain-events', baseColumns, { page, pageSize });
return ( return (
<Card title="领域事件"> <Card title="领域事件" extra={settingsButton}>
{settingsModal}
<Space wrap style={{ marginBottom: 16 }}> <Space wrap style={{ marginBottom: 16 }}>
<Select <Select
style={{ width: 180 }} style={{ width: 180 }}
@@ -7,11 +7,11 @@ import {
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
Popconfirm,
Select, Select,
Space, Space,
Table, Table,
Tag, Tag,
Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -28,6 +28,10 @@ import {
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({ const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
value, value,
@@ -65,7 +69,10 @@ export default function FulfillmentProvidersPage() {
setLoading(true); setLoading(true);
try { try {
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers'); const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
setRows(res); setRows(Array.isArray(res) ? res : []);
} catch (e) {
setRows([]);
message.error(e instanceof Error ? e.message : '加载承运商失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -91,6 +98,7 @@ export default function FulfillmentProvidersPage() {
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee, extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles, boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee, boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
deliveryHintHtml: '',
}); });
setOpen(true); setOpen(true);
} }
@@ -119,6 +127,7 @@ export default function FulfillmentProvidersPage() {
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee, extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles, boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee, boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
deliveryHintHtml: row.deliveryHintHtml || '',
}); });
setOpen(true); setOpen(true);
} }
@@ -141,6 +150,7 @@ export default function FulfillmentProvidersPage() {
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined, boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined, boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
}, },
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
}; };
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) { if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
@@ -173,9 +183,25 @@ export default function FulfillmentProvidersPage() {
void load(); void load();
} }
const columns: ColumnsType<FulfillmentProviderDto> = [ async function remove(row: FulfillmentProviderDto) {
try {
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 }, { title: '编码', dataIndex: 'code', width: 100 },
{ title: '名称', dataIndex: 'name' }, {
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
@@ -221,30 +247,45 @@ export default function FulfillmentProvidersPage() {
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime }, { title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={() => openEdit(row)}> <Space size={0}>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
</Button>
<Popconfirm
title={`确认删除承运商「${row.name}」?`}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => void remove(row)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
), ),
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, {
page: 1,
pageSize: rows.length || 20,
});
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<div> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}> title="仓配管理"
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
</Typography.Title> settings={settingsButton}
<Typography.Text type="secondary"> actions={
<Button type="primary" onClick={openCreate}>
</Typography.Text>
</div> </Button>
<Button type="primary" onClick={openCreate}> }
/>
</Button>
</Space>
<Alert <Alert
type="info" type="info"
@@ -253,7 +294,7 @@ export default function FulfillmentProvidersPage() {
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。" message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
/> />
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} /> <Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
<Modal <Modal
title={editRow ? '编辑承运商' : '注册承运商'} title={editRow ? '编辑承运商' : '注册承运商'}
@@ -276,6 +317,17 @@ export default function FulfillmentProvidersPage() {
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item
name="deliveryHintHtml"
label="配送信息提示"
extra="C 端同城送展示。支持 HTMLspan/p/br/b/strong/i/em/fontstyle 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
>
<Input.TextArea
rows={3}
maxLength={2000}
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
/>
</Form.Item>
<Divider orientation="left"></Divider> <Divider orientation="left"></Divider>
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}> <Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
+133 -46
View File
@@ -3,9 +3,14 @@ import {
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message, Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type HqProfile } from '../lib/api'; import { HQ_ADMIN_ROLES } from '@dukang/shared-types';
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { request, type HqProfile, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -17,14 +22,13 @@ type Row = {
status: string; status: string;
lastLoginAt: string | null; lastLoginAt: string | null;
createdAt: string; createdAt: string;
cityIds?: string[];
}; };
const ROLE_LABELS: Record<string, string> = { type CityOption = { id: string; name: string };
SUPER_ADMIN: '超级管理员',
OPS: '运营', const ROLE_OPTIONS = HQ_ADMIN_ROLES.map((r) => ({ value: r.value, label: r.label }));
FINANCE: '财务', const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
CUSTOMER_SERVICE: '客服',
};
export default function HqAccountsPage() { export default function HqAccountsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
@@ -46,15 +50,52 @@ export default function HqAccountsPage() {
const [detail, setDetail] = useState<Row | null>(null); const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone'; const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
const editRole = Form.useWatch('adminRole', editForm);
const createRole = Form.useWatch('adminRole', createForm);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => { useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
const columns: ColumnsType<Row> = [ useEffect(() => {
{ title: '姓名', dataIndex: 'name' }, if (!isSuperAdmin) return;
request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items))
.catch(() => {});
}, [isSuperAdmin]);
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
const baseColumns: ColumnsType<Row> = [
{
title: '姓名',
dataIndex: 'name',
render: (v, row) =>
isSuperAdmin ? (
<AdminPrimaryLink
onClick={() => {
setDetail(row);
editForm.setFieldsValue({
name: row.name,
phone: row.phone,
loginName: row.loginName,
adminRole: row.adminRole,
status: row.status,
cityIds: row.cityIds ?? [],
});
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
) : (
v
),
},
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' }, { title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 130 }, { title: '手机', dataIndex: 'phone', width: 130 },
{ {
@@ -67,7 +108,18 @@ export default function HqAccountsPage() {
</Space> </Space>
), ),
}, },
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r }, { title: '角色', dataIndex: 'adminRole', width: 130, render: (r) => ROLE_LABELS[r] || r },
{
title: '城市',
width: 160,
render: (_, r) => {
if (!r.cityIds?.length) return <Typography.Text type="secondary"></Typography.Text>;
const names = r.cityIds
.map((id) => cities.find((c) => c.id === id)?.name || id)
.join('、');
return names;
},
},
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime }, { title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
{ {
@@ -81,6 +133,7 @@ export default function HqAccountsPage() {
loginName: row.loginName, loginName: row.loginName,
adminRole: row.adminRole, adminRole: row.adminRole,
status: row.status, status: row.status,
cityIds: row.cityIds ?? [],
}); });
setDrawerOpen(true); setDrawerOpen(true);
}}></Button> }}></Button>
@@ -88,43 +141,57 @@ export default function HqAccountsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('hq-accounts', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}>HQ </Typography.Title> <AdminListHeader
{isSuperAdmin && ( title="HQ 账户"
<Button settings={settingsButton}
type="primary" actions={
onClick={() => { isSuperAdmin ? (
createForm.resetFields(); <Button
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' }); type="primary"
setCreateOpen(true); onClick={() => {
}} createForm.resetFields();
> createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
setCreateOpen(true);
</Button> }}
)} >
</Space>
</Button>
) : null
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item> <Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
<Form.Item name="adminRole" label="角色"> <Form.Item name="adminRole" label="角色">
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} /> <Select allowClear style={{ width: 160 }} options={ROLE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} <Table scroll={{ x: 'max-content' }} 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); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="编辑 HQ 账户" width={460} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={ extra={
<Button type="primary" onClick={async () => { <Button type="primary" loading={saving} onClick={async () => {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const payload = { ...v }; const v = await editForm.validateFields();
if (!payload.password) delete payload.password; const payload: Record<string, unknown> = { ...v };
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) }); if (!payload.password) delete payload.password;
message.success('已保存'); if (!String(payload.loginName ?? '').trim()) delete payload.loginName;
setDrawerOpen(false); setSaving(true);
void reload(); await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
} finally {
setSaving(false);
}
}}></Button> }}></Button>
}> }>
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
@@ -144,7 +211,15 @@ export default function HqAccountsPage() {
<Input.Password placeholder="至少 6 位" /> <Input.Password placeholder="至少 6 位" />
</Form.Item> </Form.Item>
<Form.Item name="adminRole" label="角色"> <Form.Item name="adminRole" label="角色">
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={ROLE_OPTIONS} />
</Form.Item>
<Form.Item
name="cityIds"
label="负责城市"
extra={editRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
rules={editRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
>
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
@@ -156,15 +231,19 @@ export default function HqAccountsPage() {
open={createOpen} open={createOpen}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
const v = await createForm.validateFields(); try {
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) }); const v = await createForm.validateFields();
message.success('已创建'); await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
setCreateOpen(false); message.success('已创建');
createForm.resetFields(); setCreateOpen(false);
void reload(); createForm.resetFields();
void reload();
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
}
}} }}
> >
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}> <Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS', cityIds: [] }}>
<Form.Item name="credentialType" label="创建方式"> <Form.Item name="credentialType" label="创建方式">
<Radio.Group> <Radio.Group>
<Radio value="phone"></Radio> <Radio value="phone"></Radio>
@@ -173,7 +252,15 @@ export default function HqAccountsPage() {
</Form.Item> </Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="adminRole" label="角色"> <Form.Item name="adminRole" label="角色">
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={ROLE_OPTIONS} />
</Form.Item>
<Form.Item
name="cityIds"
label="负责城市"
extra={createRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
rules={createRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
>
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
</Form.Item> </Form.Item>
{credentialType === 'phone' ? ( {credentialType === 'phone' ? (
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}> <Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
+25 -8
View File
@@ -9,6 +9,10 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log'; import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -52,12 +56,11 @@ export default function HqLogsPage() {
form.setFieldsValue(filters); form.setFieldsValue(filters);
}, [form, filters]); }, [form, filters]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作人', title: '操作人',
width: 200, width: 200,
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.hqName} primary={r.hqName}
@@ -69,7 +72,17 @@ export default function HqLogsPage() {
title: '行为', title: '行为',
dataIndex: 'actionLabel', dataIndex: 'actionLabel',
width: 160, width: 160,
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>, render: (v, r) => (
<AdminPrimaryLink
onClick={async () => {
const res = await request<Row>(`/admin/logs/hq/${r.id}`);
setDetail(res);
setDrawerOpen(true);
}}
>
<Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>
</AdminPrimaryLink>
),
}, },
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' }, { title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' }, { title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
@@ -92,12 +105,16 @@ export default function HqLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4}>HQ </Typography.Title> {settingsModal}
<Typography.Paragraph type="secondary"> <AdminListHeader
title="HQ 操作日志"
</Typography.Paragraph> settings={settingsButton}
description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
/>
<Form <Form
form={form} form={form}
@@ -150,7 +167,7 @@ export default function HqLogsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 900 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+86 -54
View File
@@ -17,6 +17,7 @@ import {
import { import {
HQ_ADMIN_ROLES, HQ_ADMIN_ROLES,
HQ_PERMISSION_CATALOG, HQ_PERMISSION_CATALOG,
computeHqEffectivePermissionKeys,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
@@ -27,6 +28,8 @@ type AccountPermRes = {
account: AccountOption; account: AccountOption;
permissionKeys: string[]; permissionKeys: string[];
rolePermissionKeys: string[]; rolePermissionKeys: string[];
grantKeys?: string[];
denyKeys?: string[];
userPermissionKeys: string[]; userPermissionKeys: string[];
effectivePermissionKeys: string[]; effectivePermissionKeys: string[];
}; };
@@ -46,11 +49,17 @@ function PermissionChecklist({
value, value,
onChange, onChange,
disabled, disabled,
roleKeys,
grantKeys,
}: { }: {
value: string[]; value: string[];
onChange: (keys: string[]) => void; onChange: (keys: string[]) => void;
disabled?: boolean; disabled?: boolean;
roleKeys?: string[];
grantKeys?: string[];
}) { }) {
const roleSet = new Set(roleKeys ?? []);
const grantSet = new Set(grantKeys ?? []);
return ( return (
<Checkbox.Group <Checkbox.Group
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -81,6 +90,13 @@ function PermissionChecklist({
{items.map((item) => ( {items.map((item) => (
<Checkbox key={item.key} value={item.key}> <Checkbox key={item.key} value={item.key}>
{item.label} {item.label}
{roleSet.has(item.key) && !value.includes(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="red"></Tag>
) : grantSet.has(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="orange"></Tag>
) : roleSet.has(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="blue"></Tag>
) : null}
</Checkbox> </Checkbox>
))} ))}
</Space> </Space>
@@ -88,7 +104,16 @@ function PermissionChecklist({
<Row gutter={[12, 10]}> <Row gutter={[12, 10]}>
{items.map((item) => ( {items.map((item) => (
<Col key={item.key} xs={24} sm={12} md={span}> <Col key={item.key} xs={24} sm={12} md={span}>
<Checkbox value={item.key}>{item.label}</Checkbox> <Checkbox value={item.key}>
{item.label}
{roleSet.has(item.key) && !value.includes(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="red"></Tag>
) : grantSet.has(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="orange"></Tag>
) : roleSet.has(item.key) ? (
<Tag style={{ marginLeft: 6 }} color="blue"></Tag>
) : null}
</Checkbox>
</Col> </Col>
))} ))}
</Row> </Row>
@@ -100,6 +125,14 @@ function PermissionChecklist({
); );
} }
function splitAccountOverrides(roleKeys: string[], effectiveKeys: string[]) {
const roleSet = new Set(roleKeys);
const effectiveSet = new Set(effectiveKeys);
const grantKeys = effectiveKeys.filter((k) => !roleSet.has(k));
const denyKeys = roleKeys.filter((k) => !effectiveSet.has(k));
return { grantKeys, denyKeys };
}
export default function HqPermissionsPage() { export default function HqPermissionsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
const [role, setRole] = useState<string>('OPS'); const [role, setRole] = useState<string>('OPS');
@@ -111,6 +144,8 @@ export default function HqPermissionsPage() {
const [accountId, setAccountId] = useState<string>(); const [accountId, setAccountId] = useState<string>();
const [accountKeys, setAccountKeys] = useState<string[]>([]); const [accountKeys, setAccountKeys] = useState<string[]>([]);
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]); const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
const [grantKeys, setGrantKeys] = useState<string[]>([]);
const [denyKeys, setDenyKeys] = useState<string[]>([]);
const [accountLoading, setAccountLoading] = useState(false); const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = useState(false); const [accountSaving, setAccountSaving] = useState(false);
@@ -121,8 +156,8 @@ export default function HqPermissionsPage() {
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN'; const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
const previewEffectiveKeys = useMemo( const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])], () => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys),
[roleInheritedKeys, accountKeys], [roleInheritedKeys, grantKeys, denyKeys],
); );
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
@@ -151,8 +186,10 @@ export default function HqPermissionsPage() {
setAccountLoading(true); setAccountLoading(true);
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`) request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
.then((res) => { .then((res) => {
setAccountKeys(res.userPermissionKeys); setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
setDenyKeys(res.denyKeys ?? []);
setRoleInheritedKeys(res.rolePermissionKeys); setRoleInheritedKeys(res.rolePermissionKeys);
setAccountKeys(res.effectivePermissionKeys);
}) })
.finally(() => setAccountLoading(false)); .finally(() => setAccountLoading(false));
}, [isSuperAdmin, accountId]); }, [isSuperAdmin, accountId]);
@@ -179,10 +216,12 @@ export default function HqPermissionsPage() {
try { try {
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, { const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ permissionKeys: accountKeys }), body: JSON.stringify({ grantKeys, denyKeys }),
}); });
setAccountKeys(res.userPermissionKeys); setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
setDenyKeys(res.denyKeys ?? []);
setRoleInheritedKeys(res.rolePermissionKeys); setRoleInheritedKeys(res.rolePermissionKeys);
setAccountKeys(res.effectivePermissionKeys);
message.success('用户权限已保存'); message.success('用户权限已保存');
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '保存失败'); message.error(e instanceof Error ? e.message : '保存失败');
@@ -204,8 +243,9 @@ export default function HqPermissionsPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
= =
// //
/
// //
</Typography.Paragraph> </Typography.Paragraph>
@@ -275,59 +315,51 @@ export default function HqPermissionsPage() {
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" /> <Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : ( ) : (
<> <>
<div style={{ marginBottom: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Tag color="blue"></Tag>
) : (
<Space wrap size={[4, 4]}>
{roleInheritedKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return (
<Tag key={key} color="blue">
{item?.label || key}
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
//
/
</Typography.Paragraph> </Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} /> <PermissionChecklist
value={accountKeys}
roleKeys={roleInheritedKeys}
grantKeys={grantKeys}
disabled={selectedIsSuperAdmin}
onChange={(nextEffective) => {
if (selectedIsSuperAdmin) return;
const next = splitAccountOverrides(roleInheritedKeys, nextEffective);
setGrantKeys(next.grantKeys);
setDenyKeys(next.denyKeys);
setAccountKeys(nextEffective);
}}
/>
{selectedIsSuperAdmin ? (
<Alert
type="info"
showIcon
style={{ marginTop: 12 }}
message="超级管理员基础权限不可撤销。危险操作请到下方以外的方式:当前勾选仅展示生效权限。"
/>
) : null}
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<span style={{ marginRight: 8 }}></span> <span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? ( <Space wrap size={[4, 4]}>
<Space wrap size={[4, 4]}> {previewEffectiveKeys.map((key) => {
<Tag></Tag> const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
{accountKeys.map((key) => { return (
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); <Tag key={key}>
return ( {item?.label || key}
<Tag key={key} color="orange"> </Tag>
{item?.label || key} );
</Tag> })}
); </Space>
})}
</Space>
) : (
<Space wrap size={[4, 4]}>
{previewEffectiveKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key}>
{item?.label || key}
</Tag>
);
})}
</Space>
)}
</div> </div>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}> <Button
type="primary"
loading={accountSaving}
disabled={selectedIsSuperAdmin}
onClick={() => void saveAccountPermissions()}
>
</Button> </Button>
</div> </div>
+100 -31
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -10,15 +11,16 @@ import {
Space, Space,
Table, Table,
Tag, Tag,
Typography,
Upload, Upload,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
INVOICE_CATEGORY_LABELS,
INVOICE_KIND_LABELS, INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS, INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS, INVOICE_TITLE_TYPE_LABELS,
type InvoiceCategory,
type InvoiceKind, type InvoiceKind,
type InvoiceStatus, type InvoiceStatus,
type InvoiceTitleType, type InvoiceTitleType,
@@ -27,6 +29,10 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload'; import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -34,6 +40,7 @@ type Row = {
orderNo?: string; orderNo?: string;
titleType: InvoiceTitleType; titleType: InvoiceTitleType;
invoiceKind: InvoiceKind; invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string; titleName: string;
status: InvoiceStatus; status: InvoiceStatus;
overdue?: boolean; overdue?: boolean;
@@ -53,6 +60,7 @@ type CreateFormValues = {
orderNo: string; orderNo: string;
titleType: InvoiceTitleType; titleType: InvoiceTitleType;
invoiceKind: InvoiceKind; invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string; titleName: string;
taxNo?: string; taxNo?: string;
addressPhone?: string; addressPhone?: string;
@@ -63,12 +71,22 @@ type CreateFormValues = {
}; };
export default function InvoicesPage() { export default function InvoicesPage() {
const [filters, setFilters] = useState<Record<string, string>>({}); const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('status')?.trim() || '';
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
const [filterForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
if (initialStatus) init.status = initialStatus;
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/invoices', '/admin/invoices',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
return qs; return qs;
}, },
[filters], [filters],
@@ -81,12 +99,29 @@ export default function InvoicesPage() {
const [createForm] = Form.useForm<CreateFormValues>(); const [createForm] = Form.useForm<CreateFormValues>();
const invoiceKind = Form.useWatch('invoiceKind', createForm); const invoiceKind = Form.useWatch('invoiceKind', createForm);
const titleType = Form.useWatch('titleType', createForm); const titleType = Form.useWatch('titleType', createForm);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
filterForm.setFieldsValue({
status: filters.status || undefined,
invoiceNo: filters.invoiceNo || undefined,
});
}, [filterForm, filters.invoiceNo, filters.status]);
async function openDetail(id: string) { async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`)); setDetail(await request(`/admin/invoices/${id}`));
setDrawerOpen(true); setDrawerOpen(true);
} }
useEffect(() => {
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.invoiceNo) === initialInvoiceNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialInvoiceNo, loading]);
async function issueWithFile(file: File) { async function issueWithFile(file: File) {
if (!detail) return false; if (!detail) return false;
setUploading(true); setUploading(true);
@@ -128,6 +163,7 @@ export default function InvoicesPage() {
orderNo: values.orderNo.trim(), orderNo: values.orderNo.trim(),
titleType: values.titleType, titleType: values.titleType,
invoiceKind: values.invoiceKind, invoiceKind: values.invoiceKind,
invoiceCategory: values.invoiceCategory,
titleName: values.titleName.trim(), titleName: values.titleName.trim(),
taxNo: values.taxNo?.trim() || undefined, taxNo: values.taxNo?.trim() || undefined,
addressPhone: values.addressPhone?.trim() || undefined, addressPhone: values.addressPhone?.trim() || undefined,
@@ -148,7 +184,7 @@ export default function InvoicesPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 }, { title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 }, { title: '订单号', dataIndex: 'orderNo', width: 160 },
{ {
@@ -158,10 +194,24 @@ export default function InvoicesPage() {
}, },
{ {
title: '票种', title: '票种',
width: 120, width: 140,
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind, render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
}, },
{ title: '名称', dataIndex: 'titleName', ellipsis: true }, {
title: '类型',
width: 88,
render: (_, r) =>
r.invoiceCategory
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
: '—',
},
{
title: '名称',
dataIndex: 'titleName',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '状态', title: '状态',
width: 110, width: 110,
@@ -186,33 +236,32 @@ export default function InvoicesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<div {settingsModal}
style={{ <AdminListHeader
display: 'flex', title="发票管理"
justifyContent: 'space-between', settings={settingsButton}
alignItems: 'center', actions={
marginBottom: 16, <Button
}} type="primary"
> onClick={() => {
<Typography.Title level={4} style={{ margin: 0 }}> createForm.setFieldsValue({
titleType: 'PERSONAL',
</Typography.Title> invoiceKind: 'NORMAL',
<Button invoiceCategory: 'LIQUOR',
type="primary" });
onClick={() => { setCreateOpen(true);
createForm.setFieldsValue({ }}
titleType: 'PERSONAL', >
invoiceKind: 'NORMAL',
}); </Button>
setCreateOpen(true); }
}} />
>
</Button>
</div>
<Form <Form
form={filterForm}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v) => { onFinish={(v) => {
@@ -231,6 +280,9 @@ export default function InvoicesPage() {
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="invoiceNo" label="申请单号">
<Input allowClear placeholder="发票申请单号" />
</Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
</Button> </Button>
@@ -240,7 +292,7 @@ export default function InvoicesPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1000 }} scroll={{ x: 'max-content' }}
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')} rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
pagination={{ pagination={{
current: page, current: page,
@@ -292,6 +344,11 @@ export default function InvoicesPage() {
<Descriptions.Item label="发票类型"> <Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]} {INVOICE_KIND_LABELS[detail.invoiceKind]}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="类型">
{detail.invoiceCategory
? INVOICE_CATEGORY_LABELS[detail.invoiceCategory]
: '—'}
</Descriptions.Item>
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item> <Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item> <Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
@@ -350,6 +407,18 @@ export default function InvoicesPage() {
}} }}
/> />
</Form.Item> </Form.Item>
<Form.Item
name="invoiceCategory"
label="类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select
options={(Object.keys(INVOICE_CATEGORY_LABELS) as InvoiceCategory[]).map((k) => ({
value: k,
label: INVOICE_CATEGORY_LABELS[k],
}))}
/>
</Form.Item>
<Form.Item <Form.Item
name="titleType" name="titleType"
label="抬头类型" label="抬头类型"
+22 -12
View File
@@ -25,6 +25,10 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload'; import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
name: string; name: string;
@@ -124,9 +128,15 @@ export default function KnowledgeBasesPage() {
} }
}; };
const columns: ColumnsType<KnowledgeBaseDto> = [ const baseColumns: ColumnsType<KnowledgeBaseDto> = [
{ title: '名称', dataIndex: 'name' }, {
{ title: '说明', dataIndex: 'description', ellipsis: true }, title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '说明', dataIndex: 'description' },
{ title: '文档数', dataIndex: 'documentCount', width: 90 }, { title: '文档数', dataIndex: 'documentCount', width: 90 },
{ {
title: '启用', title: '启用',
@@ -184,7 +194,7 @@ export default function KnowledgeBasesPage() {
const docColumns: ColumnsType<KnowledgeDocumentDto> = [ const docColumns: ColumnsType<KnowledgeDocumentDto> = [
{ title: '标题', dataIndex: 'title' }, { title: '标题', dataIndex: 'title' },
{ title: '文件', dataIndex: 'fileName', ellipsis: true }, { title: '文件', dataIndex: 'fileName' },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -250,16 +260,16 @@ export default function KnowledgeBasesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('knowledge-bases', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16 }} wrap> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
title="知识库"
</Typography.Title> settings={settingsButton}
<Typography.Text type="secondary"> description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索"
.txt/.md AI />
</Typography.Text>
</Space>
<Form <Form
form={filterForm} form={filterForm}
+21 -11
View File
@@ -25,6 +25,10 @@ import {
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
name: string; name: string;
@@ -153,15 +157,21 @@ export default function LlmConfigsPage() {
} }
}; };
const columns: ColumnsType<LlmApiConfigDto> = [ const baseColumns: ColumnsType<LlmApiConfigDto> = [
{ title: '名称', dataIndex: 'name' }, {
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '提供商', title: '提供商',
dataIndex: 'provider', dataIndex: 'provider',
width: 120, width: 120,
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p, render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
}, },
{ title: '模型', dataIndex: 'modelName', ellipsis: true }, { title: '模型', dataIndex: 'modelName' },
{ {
title: 'Key', title: 'Key',
dataIndex: 'apiKeyConfigured', dataIndex: 'apiKeyConfigured',
@@ -257,16 +267,16 @@ export default function LlmConfigsPage() {
const fullEdit = !editing || editing.canEditFull; const fullEdit = !editing || editing.canEditFull;
const { columns, settingsButton, settingsModal } = useAdminListColumns('llm-configs', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16 }} wrap> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
title="语言模型配置"
</Typography.Title> settings={settingsButton}
<Typography.Text type="secondary"> description="非超管仅可见自己创建的配置,且创建后只能改是否生效"
/>
</Typography.Text>
</Space>
<Form <Form
form={filterForm} form={filterForm}
+83 -13
View File
@@ -29,6 +29,9 @@ import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type BillRow = { type BillRow = {
id: string; id: string;
@@ -44,6 +47,13 @@ type BillRow = {
settlementMethod: string; settlementMethod: string;
status: string; status: string;
paidAt?: string | null; paidAt?: string | null;
paymentRef?: string | null;
fulfillmentProvider?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
}; };
type BillItem = { type BillItem = {
@@ -140,14 +150,30 @@ export default function LogisticsBillsPage() {
}, [summaryPeriod]); }, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) { function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({ Modal.confirm({
title: '确认结算?', title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`, content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认结算', okText: '确认结算',
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) { if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' }); await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else { } else {
await request('/admin/logistics-bills/batch-confirm', { await request('/admin/logistics-bills/batch-confirm', {
method: 'POST', method: 'POST',
@@ -217,11 +243,19 @@ export default function LogisticsBillsPage() {
} }
const summary = data?.summary; const summary = data?.summary;
const providerBank = (r: BillRow) => r.fulfillmentProvider;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0); const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
const billColumns: ColumnsType<BillRow> = [ const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true }, {
title: '账单号',
dataIndex: 'billNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '承运商', title: '承运商',
width: 140, width: 140,
@@ -248,6 +282,16 @@ export default function LogisticsBillsPage() {
width: 110, width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{
title: '收款户名',
width: 100,
render: (_, r) => providerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -303,7 +347,6 @@ export default function LogisticsBillsPage() {
{ {
title: '银行账户', title: '银行账户',
width: 180, width: 180,
ellipsis: true,
render: (_, r) => render: (_, r) =>
r.bankAccountName r.bankAccountName
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}` ? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
@@ -355,16 +398,19 @@ export default function LogisticsBillsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-logistics-bills', billColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
title="物流对账"
</Typography.Title> settings={settingsButton}
<Typography.Text type="secondary"> description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。"
/>
</Typography.Text>
</Space>
<Tabs <Tabs
items={[ items={[
@@ -501,7 +547,7 @@ export default function LogisticsBillsPage() {
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
columns={billColumns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedKeys, selectedRowKeys: selectedKeys,
@@ -550,7 +596,31 @@ export default function LogisticsBillsPage() {
<Descriptions.Item label="结算时间"> <Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'} {detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions> </Descriptions>
{providerBank(detail) ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="户名">
{providerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户银行">
{providerBank(detail)?.bankName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户支行">
{providerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{providerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Table <Table
rowKey="id" rowKey="id"
size="small" size="small"
+391 -120
View File
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Checkbox, Checkbox,
Col,
Collapse, Collapse,
DatePicker,
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
@@ -12,6 +14,7 @@ import {
InputNumber, InputNumber,
Modal, Modal,
Radio, Radio,
Row,
Select, Select,
Space, Space,
Table, Table,
@@ -20,10 +23,17 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { downloadBase64File } from '../lib/exportExcel';
import { import {
ADMIN_OPTIONS_PAGE_SIZE, ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS, DELIVERY_PROVIDER_LABELS,
DELIVERY_TYPE_LABELS,
ORDER_STATUS_COLORS, ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS, ORDER_STATUS_LABELS,
ORDER_STATUS_OPERATOR_LABELS, ORDER_STATUS_OPERATOR_LABELS,
@@ -130,6 +140,117 @@ type OrderDetail = AdminOrderRow & {
type ShipMode = 'WAREHOUSE' | 'EXPRESS'; type ShipMode = 'WAREHOUSE' | 'EXPRESS';
type OrderExportScope = 'filter' | 'selected';
type OrderExportFormat = 'xlsx' | 'pdf';
type OrderExportFilters = {
orderNo?: string;
status?: string | string[];
orderType?: string;
cityId?: string;
receiverPhone?: string;
fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string;
dateRange?: [Dayjs, Dayjs];
};
type OrderExportResult = {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
};
const ORDER_TYPE_OPTIONS = Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => ({ value, label }));
const DELIVERY_TYPE_OPTIONS = [
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
];
function isOnSitePickupOrder(row: Pick<AdminOrderRow, 'deliveryType' | 'receiverProvince' | 'receiverCity' | 'receiverAddress'>) {
return (
row.deliveryType === 'ON_SITE_PICKUP' ||
row.receiverAddress === '现场取货' ||
row.receiverAddress === '现场提货' ||
(row.receiverProvince === '现场' && row.receiverCity === '现场')
);
}
function formatReceiverAddress(row: AdminOrderRow) {
if (isOnSitePickupOrder(row)) return '现场取货';
const region = [row.receiverProvince, row.receiverCity, row.receiverDistrict].filter(Boolean).join('');
const detail = row.receiverAddress || '';
if (!region) return detail;
if (!detail || detail.startsWith(region)) return detail || region;
return `${region}${detail}`;
}
function datePresetRange(kind: 'day' | 'week' | 'month' | 'quarter' | 'year'): [Dayjs, Dayjs] {
const now = dayjs();
if (kind === 'day') return [now.startOf('day'), now.endOf('day')];
if (kind === 'week') {
const monday = now.startOf('day').subtract((now.day() + 6) % 7, 'day');
return [monday, monday.add(6, 'day').endOf('day')];
}
if (kind === 'month') return [now.startOf('month'), now.endOf('month')];
if (kind === 'quarter') {
const start = now.month(Math.floor(now.month() / 3) * 3).startOf('month');
return [start, start.add(2, 'month').endOf('month')];
}
return [now.startOf('year'), now.endOf('year')];
}
const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year'; label: string }> = [
{ key: 'day', label: '当日' },
{ key: 'week', label: '当周' },
{ key: 'month', label: '当月' },
{ key: 'quarter', label: '当季' },
{ key: 'year', label: '当年' },
];
function formatBenefitBrief(row: AdminOrderRow) {
const coupon = row.benefitCoupon;
if (coupon) {
return `总额¥${Number(coupon.totalAmount ?? 0).toFixed(0)} / 已用¥${Number(coupon.usedAmount ?? 0).toFixed(0)} / 余¥${Number(coupon.balance ?? 0).toFixed(0)}`;
}
if (row.benefitAmount != null) return `¥${Number(row.benefitAmount).toFixed(0)}`;
return '—';
}
function selectedStatuses(status: unknown): string[] {
if (Array.isArray(status)) return status.filter((s): s is string => Boolean(s));
if (typeof status === 'string' && status) return [status];
return [];
}
function buildExportPayload(
scope: OrderExportScope,
format: OrderExportFormat,
filters: OrderExportFilters,
selectedIds: string[],
) {
const payload: Record<string, unknown> = { scope, format };
if (scope === 'selected') {
payload.ids = selectedIds;
return payload;
}
if (filters.orderNo) payload.orderNo = filters.orderNo;
const statuses = selectedStatuses(filters.status);
if (statuses.length) payload.status = statuses;
if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.excludeTest) payload.excludeTest = true;
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
return payload;
}
function orderProductRows(detail: OrderDetail): AdminOrderItem[] { function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
if (detail.items?.length) return detail.items; if (detail.items?.length) return detail.items;
if (!detail.productName) return []; if (!detail.productName) return [];
@@ -162,6 +283,9 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
} }
export default function OrdersPage() { export default function OrdersPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [shipForm] = Form.useForm(); const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm(); const [logisticsForm] = Form.useForm();
@@ -172,6 +296,7 @@ export default function OrdersPage() {
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<OrderDetail | null>(null); const [detail, setDetail] = useState<OrderDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const deepLinkOpenedRef = useRef(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null); const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
@@ -189,8 +314,11 @@ export default function OrdersPage() {
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]); const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false); const [proxyOpen, setProxyOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false); const [trackOpen, setTrackOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
const selectedOrders = useMemo( const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -201,6 +329,21 @@ export default function OrdersPage() {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
useEffect(() => {
if (initialOrderNo) {
form.setFieldsValue({ orderNo: initialOrderNo });
}
}, [form, initialOrderNo]);
useEffect(() => {
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.orderNo) === initialOrderNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialOrderNo, loading]);
async function openRedeemDetail(redeemId: string) { async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true); setRedeemDetailLoading(true);
setRedeemDrawerOpen(true); setRedeemDrawerOpen(true);
@@ -220,19 +363,23 @@ export default function OrdersPage() {
try { try {
const values = form.getFieldsValue(); const values = form.getFieldsValue();
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
if (values.orderNo) qs.set('orderNo', values.orderNo); if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status); for (const status of selectedStatuses(values.status)) qs.append('status', status);
if (values.orderType) qs.set('orderType', values.orderType); if (values.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true'); if (values.excludeTest) qs.set('excludeTest', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res); setData(res);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [form, page, pageSize]); }, [form, page, pageSize, initialOrderNo]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -433,79 +580,135 @@ export default function OrdersPage() {
} }
} }
const columns: ColumnsType<AdminOrderRow> = [ async function submitExport(scope: OrderExportScope) {
const values = form.getFieldsValue() as OrderExportFilters;
if (scope === 'selected' && !selectedRowKeys.length) {
message.warning('请先勾选要导出的订单');
return;
}
setExporting(true);
try {
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
const result = await request<OrderExportResult>('/admin/orders/export', {
method: 'POST',
body: JSON.stringify(payload),
});
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条订单`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(false);
}
}
const baseColumns: ColumnsType<AdminOrderRow> = [
{ {
title: '订单号', title: '订单号',
dataIndex: 'orderNo', dataIndex: 'orderNo',
width: 200, width: 180,
render: (v, row) => ( render: (v, row) => (
<Space size={4}> <Space size={4}>
<span>{v}</span> <AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null} {row.isTest ? <Tag color="orange"></Tag> : null}
</Space> </Space>
), ),
}, },
{ {
title: '城市', title: '用户',
width: 90, key: 'user',
render: (_, row) => row.city?.name || '—', width: 120,
render: (_, row) =>
row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{row.user.userNo}
</AdminPrimaryLink>
) : (
(row.user?.userNo || '—')
),
}, },
{ {
title: '商品', title: '商品',
key: 'productName',
width: 180, width: 180,
ellipsis: true, render: (_, row) => (
render: (_, row) => {
const name = row.productName || '—';
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
return (
<span title={row.productSpec ? `${name}${row.productSpec}` : name}>
{name}{qty}
</span>
);
},
},
{
title: '状态',
dataIndex: 'status',
width: 140,
render: (s, row) => (
<Space size={4} wrap> <Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag> <span>{row.productName || '—'}</span>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null} {row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? ( {row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple" title={row.proxyPartnerName || undefined}> <Tag color="purple"></Tag>
</Tag>
) : null} ) : null}
</Space> </Space>
), ),
}, },
{ {
title: '配送', title: '规格',
dataIndex: 'productSpec',
width: 140,
render: (v: string | undefined) => v || '—',
},
{
title: '数量',
dataIndex: 'quantity',
width: 80,
render: (v: number | undefined, row) =>
v == null ? '—' : `${v}${row.saleUnit === 'BOX' ? '箱' : '瓶'}`,
},
{
title: '配送方式',
dataIndex: 'deliveryType', dataIndex: 'deliveryType',
width: 90,
render: (v) =>
v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城',
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
{ title: '手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户',
dataIndex: ['user', 'userNo'],
width: 110,
render: (_, row) => row.user?.userNo || '—',
},
{
title: '快递',
width: 100, width: 100,
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—', render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: string) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v: number) => `¥${v}`,
},
{
title: '运费',
key: 'logisticsFee',
width: 90,
render: (_, row) =>
row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`,
},
{
title: '好客权益',
width: 200,
render: (_, row) => formatBenefitBrief(row),
},
{
title: '收货人',
dataIndex: 'receiverName',
width: 90,
render: (v: string | undefined) => v || '—',
},
{
title: '电话',
dataIndex: 'receiverPhone',
width: 120,
render: (v: string | undefined) => v || '—',
},
{
title: '地址',
key: 'receiverAddress',
width: 260,
render: (_, row) => formatReceiverAddress(row) || '—',
}, },
{ {
title: '下单时间', title: '下单时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
width: 170, width: 170,
render: (v) => new Date(v).toLocaleString('zh-CN'), render: fmtTime,
}, },
{ {
title: '操作', title: '操作',
@@ -537,27 +740,28 @@ export default function OrdersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <AdminListHeader
<Space> title="订单监控"
{canProxyOrder ? ( settings={settingsButton}
<Button type="primary" onClick={() => setProxyOpen(true)}> actions={
<>
</Button> <Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
) : null} {canProxyOrder ? (
{canDeleteOrders ? ( <Button type="primary" onClick={() => setProxyOpen(true)}>
<Button
danger </Button>
disabled={!selectedRowKeys.length} ) : null}
onClick={() => setBatchDeleteOpen(true)} </>
> }
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} />
</Button>
) : null}
</Space>
</Space>
{!canDeleteOrders && profile ? ( {!canDeleteOrders && profile ? (
<Alert <Alert
@@ -573,54 +777,117 @@ export default function OrdersPage() {
/> />
) : null} ) : null}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号"> <Row gutter={[16, 8]}>
<Input placeholder="DK..." allowClear /> <Col xs={24} md={14} lg={12}>
</Form.Item> <Form.Item label="下单日期" style={{ marginBottom: 12 }}>
<Form.Item name="status" label="状态"> <Space direction="vertical" size={8} style={{ width: '100%' }}>
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Form.Item name="dateRange" noStyle>
</Form.Item> <DatePicker.RangePicker allowClear style={{ width: '100%' }} />
<Form.Item name="orderType" label="类型"> </Form.Item>
<Select <Space size={8} wrap>
allowClear {DATE_PRESETS.map((preset) => (
style={{ width: 120 }} <Button
placeholder="全部" key={preset.key}
options={[ size="small"
{ value: 'NORMAL', label: '普通订单' }, onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
{ value: 'PROXY', label: '代下单' }, >
]} {preset.label}
/> </Button>
</Form.Item> ))}
<Form.Item name="cityId" label="城市"> </Space>
<Select </Space>
allowClear </Form.Item>
showSearch </Col>
optionFilterProp="label" <Col xs={24} sm={12} md={8} lg={6}>
style={{ width: 140 }} <Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
placeholder="全部" <Select
options={cities.map((c) => ({ value: c.id, label: c.name }))} mode="multiple"
/> allowClear
</Form.Item> maxTagCount="responsive"
<Form.Item name="receiverPhone" label="收货手机"> placeholder="全部"
<Input allowClear /> options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
</Form.Item> />
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked"> </Form.Item>
<Select </Col>
allowClear <Col xs={12} sm={6} md={5} lg={3}>
style={{ width: 140 }} <Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
placeholder="全部" <Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
options={[{ value: true, label: '仅待确认大单' }]} </Form.Item>
/> </Col>
</Form.Item> <Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="excludeTest" valuePropName="checked"> <Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
<Checkbox></Checkbox> <Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item> </Col>
<Space> <Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={6}>
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
<Input allowClear placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={8} lg={8}>
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
<Space size={16} wrap>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
</Space>
</Form.Item>
</Col>
</Row>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
{canExportOrders ? (
<Space size={12} wrap>
<Radio.Group
optionType="button"
buttonStyle="solid"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<Radio.Button value="xlsx">Excel</Radio.Button>
<Radio.Button value="pdf">PDF</Radio.Button>
</Radio.Group>
<Button
disabled={!selectedRowKeys.length}
loading={exporting}
onClick={() => void submitExport('selected')}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
<Button loading={exporting} onClick={() => void submitExport('filter')}>
</Button>
</Space>
) : <span />}
<Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button> <Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space> </Space>
</Form.Item> </div>
</Form> </Form>
<Table <Table
@@ -628,16 +895,18 @@ export default function OrdersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 'max-content' }}
rowSelection={canDeleteOrders ? { rowSelection={{
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
} : undefined} }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
total: data?.total ?? 0, total: data?.total ?? 0,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => { onChange: (p, ps) => {
setPage(p); setPage(p);
setPageSize(ps); setPageSize(ps);
@@ -810,7 +1079,7 @@ export default function OrdersPage() {
locale={{ emptyText: '暂无关联核销单' }} locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []} dataSource={detail.redeemRecords ?? []}
columns={[ columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true }, { title: '核销号', dataIndex: 'redeemNo', width: 160 },
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
@@ -821,7 +1090,6 @@ export default function OrdersPage() {
{ {
title: '门店', title: '门店',
dataIndex: ['store', 'name'], dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row) => render: (v: string | undefined, row) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—', v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
}, },
@@ -897,6 +1165,11 @@ export default function OrdersPage() {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item> <Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item> <Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
<Descriptions.Item label="运费">
{detail.delivery.logisticsFee == null
? '—'
: `¥${Number(detail.delivery.logisticsFee).toFixed(2)}`}
</Descriptions.Item>
{detail.delivery.manualQueryUrl && ( {detail.delivery.manualQueryUrl && (
<Descriptions.Item label="查询链接"> <Descriptions.Item label="查询链接">
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer"> <a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
@@ -937,7 +1210,6 @@ export default function OrdersPage() {
{ {
title: '备注', title: '备注',
dataIndex: 'remark', dataIndex: 'remark',
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -1232,7 +1504,6 @@ export default function OrdersPage() {
{ {
title: '商品', title: '商品',
width: 160, width: 160,
ellipsis: true,
render: (_, row) => render: (_, row) =>
row.productName row.productName
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}` ? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
@@ -5,8 +5,11 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types'; import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type PartnerOption = { id: string; companyName: string }; type PartnerOption = { id: string; companyName: string };
type ParentAccount = { id: string; name: string; phone: string }; type ParentAccount = { id: string; name: string; phone: string };
@@ -169,27 +172,24 @@ export default function PartnerAccountsPage() {
return keys; return keys;
} }
const columns: ColumnsType<AccountTreeRow> = [ const baseColumns: ColumnsType<AccountTreeRow> = [
{ {
title: '姓名 / 类型', title: '姓名 / 类型',
width: 200, width: 200,
ellipsis: true,
render: (_, row) => ( render: (_, row) => (
<AdminCellLine <span className="admin-cell-line">
primary={row.name} <AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
secondary={ <span className="admin-cell-line-secondary">
row.parentAccountId {' · '}
? `子账号 · ${staffRoleLabel(row.staffRole)}` {row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
: '主账号' </span>
} </span>
/>
), ),
}, },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '开城合伙人', title: '开城合伙人',
width: 140, width: 140,
ellipsis: true,
render: (_, row) => row.companyName || row.parent?.name || '—', render: (_, row) => row.companyName || row.parent?.name || '—',
}, },
{ {
@@ -254,16 +254,19 @@ export default function PartnerAccountsPage() {
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('partner-accounts', baseColumns, {
page: 1,
pageSize: treeData.length || 20,
});
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<div> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="合伙人子账号"
<Typography.Text type="secondary"> settings={settingsButton}
description="主账号在「开城合伙人」创建;此处仅管理子账号树与权限"
</Typography.Text> />
</div>
</Space>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -305,7 +308,7 @@ export default function PartnerAccountsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={treeData} dataSource={treeData}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={false} pagination={false}
expandable={{ expandable={{
expandedRowKeys, expandedRowKeys,
@@ -399,7 +402,7 @@ export default function PartnerAccountsPage() {
label: `账单 (${detail.bills?.length ?? 0})`, label: `账单 (${detail.bills?.length ?? 0})`,
children: ( children: (
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns} <Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} /> dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 'max-content' }} />
), ),
}, },
{ {
@@ -407,7 +410,7 @@ export default function PartnerAccountsPage() {
label: `名下订单 (${detail.orders?.length ?? 0})`, label: `名下订单 (${detail.orders?.length ?? 0})`,
children: ( children: (
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns} <Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} /> dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} />
), ),
}, },
]} /> ]} />
+133 -19
View File
@@ -3,6 +3,8 @@ import {
Button, Button,
Card, Card,
DatePicker, DatePicker,
Descriptions,
Drawer,
Form, Form,
Input, Input,
Modal, Modal,
@@ -20,6 +22,10 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -31,8 +37,22 @@ type Row = {
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
rejectReason?: string | null; rejectReason?: string | null;
partner?: { companyName?: string; phone?: string }; paymentRef?: string | null;
partnerAccount?: { companyName?: string; phone?: string }; paidAt?: string | null;
partner?: {
companyName?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
partnerAccount?: {
companyName?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
}; };
type PartnerOption = { id: string; companyName: string; phone?: string }; type PartnerOption = { id: string; companyName: string; phone?: string };
@@ -77,6 +97,8 @@ export default function PartnerBillsPage() {
const [rejectForm] = Form.useForm(); const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false); const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]); const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -127,17 +149,30 @@ export default function PartnerBillsPage() {
} }
function markPaid(ids: string[], amountHint?: number) { function markPaid(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({ Modal.confirm({
title: '确认打款?', title: '确认打款?',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`, content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款', okText: '确认打款',
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) { if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body });
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
} else { } else {
await request('/admin/partner-bills/batch-mark-paid', { await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST', method: 'POST',
@@ -182,6 +217,12 @@ export default function PartnerBillsPage() {
} }
} }
async function openDetail(id: string) {
const d = await request<Row>(`/admin/partner-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() { async function exportExcel() {
setExporting(true); setExporting(true);
try { try {
@@ -201,17 +242,24 @@ export default function PartnerBillsPage() {
const summary = data?.summary; const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED'); const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canPay = selectedRows.filter((r) => r.status === 'UNPAID'); const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0); const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, {
title: '账单号',
dataIndex: 'billNo',
width: 180,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '合伙人', title: '合伙人',
width: 160, width: 160,
ellipsis: true,
render: (_, r) => partnerName(r), render: (_, r) => partnerName(r),
}, },
{ {
@@ -238,6 +286,16 @@ export default function PartnerBillsPage() {
width: 110, width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{
title: '收款户名',
width: 100,
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -259,6 +317,9 @@ export default function PartnerBillsPage() {
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0} wrap> <Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && ( {(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}> <Button type="link" size="small" onClick={() => sendBills([row.id])}>
@@ -284,16 +345,16 @@ export default function PartnerBillsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
title="合伙人账单"
</Typography.Title> settings={settingsButton}
<Typography.Text type="secondary"> description="每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款"
1 8:00 />
</Typography.Text>
</Space>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
@@ -395,7 +456,7 @@ export default function PartnerBillsPage() {
selectedRowKeys: selectedKeys, selectedRowKeys: selectedKeys,
onChange: setSelectedKeys, onChange: setSelectedKeys,
}} }}
scroll={{ x: 1280 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -465,6 +526,59 @@ export default function PartnerBillsPage() {
</Button> </Button>
</Form> </Form>
</Modal> </Modal>
<Drawer
title="合伙人账单明细"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={560}
>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="订单佣金">
¥{Number(detail.orderCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销佣金">
¥{Number(detail.redeemCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="应付合计">
¥{Number(detail.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLORS[detail.status] || 'default'}>
{STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{partnerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{partnerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{partnerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div> </div>
); );
} }
+25 -9
View File
@@ -14,6 +14,10 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -71,12 +75,11 @@ export default function PartnerLogsPage() {
form.setFieldsValue(filters); form.setFieldsValue(filters);
}, [form, filters]); }, [form, filters]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '合伙人', title: '合伙人',
width: 180, width: 180,
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
r.isSubAccount ? ( r.isSubAccount ? (
<Tag color="blue"></Tag> <Tag color="blue"></Tag>
@@ -88,7 +91,6 @@ export default function PartnerLogsPage() {
{ {
title: '账号', title: '账号',
width: 150, width: 150,
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.accountName} primary={r.accountName}
@@ -108,7 +110,21 @@ export default function PartnerLogsPage() {
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag> <Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 180 }, {
title: '事件',
dataIndex: 'eventName',
width: 180,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '关联', title: '关联',
width: 120, width: 120,
@@ -116,7 +132,6 @@ export default function PartnerLogsPage() {
}, },
{ {
title: '摘要', title: '摘要',
ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson), render: (_, r) => summarizeExtra(r.extraJson),
}, },
{ {
@@ -137,11 +152,12 @@ export default function PartnerLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4} style={{ marginBottom: 16 }}> {settingsModal}
<AdminListHeader title="合伙人日志" settings={settingsButton} />
</Typography.Title>
<Segmented <Segmented
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category} value={category}
@@ -216,7 +232,7 @@ export default function PartnerLogsPage() {
setPageSize(ps); setPageSize(ps);
}, },
}} }}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
/> />
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}> <Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && ( {detail && (
+24 -9
View File
@@ -27,6 +27,10 @@ import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -173,17 +177,22 @@ export default function PartnersPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 100 }, { title: '城市', dataIndex: 'cityName', width: 100 },
{ {
title: '区县', title: '区县',
dataIndex: 'districtCodes', dataIndex: 'districtCodes',
width: 160, width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) => render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
}, },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true }, {
title: '公司名',
dataIndex: 'companyName',
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
),
},
{ title: '主账号', dataIndex: 'phone', width: 130 }, { title: '主账号', dataIndex: 'phone', width: 130 },
{ {
title: '管辖', title: '管辖',
@@ -205,14 +214,20 @@ export default function PartnersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <AdminListHeader
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}> title="开城合伙人"
settings={settingsButton}
</Button> actions={
</Space> <Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
</Button>
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item> <Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item> <Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
+18 -5
View File
@@ -23,6 +23,10 @@ import {
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = { const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange', PENDING: 'orange',
@@ -94,13 +98,19 @@ export default function PendingRedeemPage() {
} }
} }
const columns: ColumnsType<RedeemPendingItem> = [ const baseColumns: ColumnsType<RedeemPendingItem> = [
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 }, {
title: '待处理单号',
dataIndex: 'pendingNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '核销码 ID', title: '核销码 ID',
dataIndex: 'redeemToken', dataIndex: 'redeemToken',
width: 160, width: 160,
ellipsis: true,
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>, render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>,
}, },
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' }, { title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
@@ -132,9 +142,12 @@ export default function PendingRedeemPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> {settingsModal}
<AdminListHeader title="待处理核销(弱网兜底)" settings={settingsButton} />
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -175,7 +188,7 @@ export default function PendingRedeemPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -13,6 +13,9 @@ import { useAdminList } from '../lib/useAdminList';
import DetailImageUrlList from '../components/DetailImageUrlList'; import DetailImageUrlList from '../components/DetailImageUrlList';
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates'; import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates'; import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = ProductDetailTemplateDto; type Row = ProductDetailTemplateDto;
@@ -136,10 +139,26 @@ export default function ProductDetailTemplatesPage() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 120 }, { title: '编码', dataIndex: 'code', width: 120 },
{ title: '名称', dataIndex: 'name', width: 120 }, {
{ title: '说明', dataIndex: 'description', width: 200, ellipsis: true }, title: '名称',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapToForm(d));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ title: '说明', dataIndex: 'description', width: 200 },
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') }, { title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 }, { title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '排序', dataIndex: 'sortOrder', width: 60 },
@@ -158,10 +177,17 @@ export default function ProductDetailTemplatesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('product-detail-templates', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}></Button> <Button type="primary" onClick={() => setCreateOpen(true)}></Button>
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
@@ -175,7 +201,7 @@ export default function ProductDetailTemplatesPage() {
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }} <Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
+248 -63
View File
@@ -12,7 +12,12 @@ import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload'; import MultiImageUpload from '../components/MultiImageUpload';
import DetailImageUrlList from '../components/DetailImageUrlList'; import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker'; import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form'; import type { FormInstance } from 'antd/es/form';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type ProductDetailContentDto = { type ProductDetailContentDto = {
storyTitle?: string; storyTitle?: string;
@@ -20,6 +25,22 @@ type ProductDetailContentDto = {
features?: Array<{ icon: string; title: string; desc: string }>; features?: Array<{ icon: string; title: string; desc: string }>;
}; };
type SkuListRow = {
id: string;
skuCode: string;
barcode69: string;
specText: string;
price: number;
benefitAmount: number;
status: string;
isDefault?: boolean;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
soldBottles?: number;
virtual?: boolean;
};
type Row = { type Row = {
id: string; id: string;
skuCode: string; skuCode: string;
@@ -29,9 +50,15 @@ type Row = {
aromaType: string; aromaType: string;
spec: string; spec: string;
price: number; price: number;
priceMin?: number;
priceMax?: number;
soldBottles?: number;
benefitAmount: number; benefitAmount: number;
status: string; status: string;
sortOrder: number; sortOrder: number;
skuCount?: number;
specEnabled?: boolean;
skus?: SkuListRow[];
allowOnSitePickup?: boolean; allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean; allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean; allowCrossCityDelivery?: boolean;
@@ -213,7 +240,33 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
); );
} }
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) { function FulfillmentTags(row: {
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
allowOnSitePickup?: boolean;
}) {
return (
<Space size={[0, 4]} wrap>
{row.allowOnlinePurchase !== false ? <Tag color="blue">线</Tag> : null}
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
<Tag color="cyan"></Tag>
) : null}
{row.allowOnSitePickup ? <Tag color="green"></Tag> : null}
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag></Tag> : null}
</Space>
);
}
function BaseInfoFields({
mode,
form,
hideFulfillment,
}: {
mode: 'create' | 'edit';
form: FormInstance;
/** 多规格商品:履约只在规格 SKU 上编辑 */
hideFulfillment?: boolean;
}) {
return ( return (
<> <>
{mode === 'create' && ( {mode === 'create' && (
@@ -221,8 +274,13 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item label="SKU"> <Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" /> <Input disabled placeholder="保存后自动生成" />
</Form.Item> </Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}> <Form.Item
<Input /> name="barcode69"
label="69码"
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
>
<Input placeholder="默认规格 69 码" />
</Form.Item> </Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}> <Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
@@ -250,39 +308,47 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item name="sortOrder" label="排序"> <Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item {hideFulfillment ? (
name="allowOnlinePurchase" <Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
label="允许线上购买" 线 / / SKU
valuePropName="checked" </Typography.Paragraph>
extra="配送到址(同城)" ) : (
> <>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
{() => (
<Form.Item <Form.Item
name="allowCrossCityDelivery" name="allowOnlinePurchase"
label="允许跨城配送" label="允许线上购买"
valuePropName="checked" valuePropName="checked"
extra="须先开启线上购买" extra="配送到址(同城)"
> >
<Switch <Switch
checkedChildren="开" checkedChildren="开"
unCheckedChildren="关" unCheckedChildren="关"
disabled={!form.getFieldValue('allowOnlinePurchase')} onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/> />
</Form.Item> </Form.Item>
)} <Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
</Form.Item> {() => (
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked"> <Form.Item
<Switch checkedChildren="开" unCheckedChildren="关" /> name="allowCrossCityDelivery"
</Form.Item> label="允许跨城配送"
valuePropName="checked"
extra="须先开启线上购买"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
disabled={!form.getFieldValue('allowOnlinePurchase')}
/>
</Form.Item>
)}
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
</>
)}
<VisibilityWhitelistFields form={form} /> <VisibilityWhitelistFields form={form} />
<Form.Item name="coverUrl" label="封面"> <Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" /> <OssUpload bizType="COVER" mediaType="IMAGE" />
@@ -325,13 +391,43 @@ export default function ProductsPage() {
} }
} }
const columns: ColumnsType<Row> = useMemo(() => [ const baseColumns: ColumnsType<Row> = useMemo(() => [
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v }, { title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true }, { title: '品名', dataIndex: 'name', width: 200, render: (v, row) => (
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` }, <AdminPrimaryLink onClick={async () => {
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` }, const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
setDrawerOpen(true);
}}>{v}</AdminPrimaryLink>
) },
{
title: '累计销售',
dataIndex: 'soldBottles',
width: 110,
render: (v: number | undefined) => `累计 ${v ?? 0}`,
},
{
title: '售价',
key: 'priceRange',
width: 120,
render: (_, row) => {
const min = row.priceMin ?? row.price;
const max = row.priceMax ?? row.price;
if (min === max) return `¥${min}`;
return `¥${min} ~ ¥${max}`;
},
},
{
title: '规格数',
dataIndex: 'skuCount',
width: 80,
render: (v: number | undefined, row) => {
const real = v ?? 0;
if (real > 0) return real;
return row.skus?.some((s) => s.virtual) ? 1 : 0;
},
},
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => ( { title: '状态', dataIndex: 'status', width: 80, render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag> <Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) }, ) },
@@ -342,23 +438,7 @@ export default function ProductsPage() {
render: (v: boolean) => render: (v: boolean) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>, v ? <Tag color="orange"></Tag> : <Tag></Tag>,
}, },
{
title: '履约',
key: 'fulfillment',
width: 160,
render: (_, row) => (
<Space size={[0, 4]} wrap>
{row.allowOnlinePurchase !== false ? <Tag color="blue">线</Tag> : null}
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
<Tag color="cyan"></Tag>
) : null}
{row.allowOnSitePickup ? <Tag color="green"></Tag> : null}
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag></Tag> : null}
</Space>
),
},
{ title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '排序', dataIndex: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 120, title: '操作', width: 120,
render: (_, row) => ( render: (_, row) => (
@@ -371,7 +451,7 @@ export default function ProductsPage() {
}}></Button> }}></Button>
<Popconfirm <Popconfirm
title="确认删除该商品?" title="确认删除该商品?"
description={`将永久删除「${row.name}${row.skuCode},此操作不可恢复。`} description={`将永久删除「${row.name}」,此操作不可恢复。`}
okText="确认删除" okText="确认删除"
cancelText="取消" cancelText="取消"
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
@@ -384,14 +464,62 @@ export default function ProductsPage() {
}, },
], [detail, editForm]); ], [detail, editForm]);
const skuColumns: ColumnsType<SkuListRow> = useMemo(() => [
{
title: '规格',
dataIndex: 'specText',
width: 140,
render: (v: string, row) => (
<Space size={4}>
<span>{v || '默认'}</span>
{row.isDefault ? <Tag color="blue"></Tag> : null}
{row.virtual ? <Tag>SKU</Tag> : null}
</Space>
),
},
{ title: 'SKU', dataIndex: 'skuCode', width: 110 },
{ title: '69码', dataIndex: 'barcode69', width: 160 },
{ title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` },
{
title: '累计销售',
dataIndex: 'soldBottles',
width: 100,
render: (v: number | undefined) => `${v ?? 0}`,
},
{
title: '履约',
key: 'fulfillment',
width: 160,
render: (_, row) => <FulfillmentTags {...row} />,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
),
},
], []);
const { columns, settingsButton, settingsModal } = useAdminListColumns('products', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <AdminListHeader
<Button type="primary" onClick={() => setCreateOpen(true)}></Button> title="商品管理"
</Space> settings={settingsButton}
actions={<Button type="primary" onClick={() => setCreateOpen(true)}></Button>}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item> <Form.Item name="name" label="名称">
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item> </Form.Item>
@@ -400,17 +528,46 @@ export default function ProductsPage() {
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }} <Table
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> rowKey="id"
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)} className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
expandable={{
rowExpandable: () => true,
expandedRowRender: (row) => (
<div style={{ margin: '-8px -8px -8px 24px', padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
<Table
size="small"
rowKey="id"
pagination={false}
columns={skuColumns}
dataSource={row.skus ?? []}
scroll={{ x: 'max-content' }}
style={{ background: 'transparent' }}
/>
</div>
),
}}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }}
/>
<Drawer title="编辑商品" width={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
<Button type="primary" onClick={async () => { <Button type="primary" onClick={async () => {
const v = await editForm.validateFields(); const v = await editForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) { const multiSku = Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1;
if (!multiSku && !v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一'); message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return; return;
} }
const payload = buildProductPayload(v); const payload = buildProductPayload(v);
if (multiSku) {
delete (payload as { allowOnlinePurchase?: boolean }).allowOnlinePurchase;
delete (payload as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery;
delete (payload as { allowOnSitePickup?: boolean }).allowOnSitePickup;
}
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) }); await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存'); message.success('已保存');
setDrawerOpen(false); setDrawerOpen(false);
@@ -420,13 +577,24 @@ export default function ProductsPage() {
{detail && ( {detail && (
<> <>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item> <Descriptions.Item label="默认 SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item> <Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item> <Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
</Descriptions> </Descriptions>
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Tabs items={[ <Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> }, {
key: 'base',
label: '基础信息',
children: (
<BaseInfoFields
mode="edit"
form={editForm}
hideFulfillment={Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1}
/>
),
},
{ {
key: 'detail', key: 'detail',
label: '详情页', label: '详情页',
@@ -437,6 +605,23 @@ export default function ProductsPage() {
/> />
), ),
}, },
{
key: 'specs',
label: '规格与 SKU',
children: (
<ProductSpecsEditor
productId={String(detail.id)}
initialAttrs={(detail.specAttrs as never) ?? []}
initialSkus={(detail.skus as never) ?? []}
onSaved={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
void reload();
}}
/>
),
},
]} /> ]} />
</Form> </Form>
</> </>
+22 -7
View File
@@ -14,6 +14,10 @@ import {
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = PromoCodeItem; type Row = PromoCodeItem;
@@ -74,8 +78,15 @@ export default function PromoCodesPage() {
void loadScenes(); void loadScenes();
}, []); }, []);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, {
title: '名称',
dataIndex: 'name',
width: 160,
render: (v, row) => (
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}`)}>{v}</AdminPrimaryLink>
),
},
{ title: '码值', dataIndex: 'code', width: 110 }, { title: '码值', dataIndex: 'code', width: 110 },
{ {
title: '场景', title: '场景',
@@ -160,12 +171,16 @@ export default function PromoCodesPage() {
} }
} }
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-codes', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title> <AdminListHeader
<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button> title="推广码管理"
</div> settings={settingsButton}
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>}
/>
<Form <Form
form={filterForm} form={filterForm}
@@ -194,7 +209,7 @@ export default function PromoCodesPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+34 -6
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Checkbox, 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 type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
@@ -6,6 +7,10 @@ import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDesc
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -26,8 +31,14 @@ function maskPhone(phone: string | null | undefined) {
} }
export default function RedeemRecordsPage() { export default function RedeemRecordsPage() {
const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string | boolean>>({}); const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records', '/admin/redeem-records',
() => { () => {
@@ -43,6 +54,11 @@ export default function RedeemRecordsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
}, [form, initialRedeemNo]);
async function openDetail(id: string) { async function openDetail(id: string) {
setDetailLoading(true); setDetailLoading(true);
@@ -54,14 +70,23 @@ export default function RedeemRecordsPage() {
} }
} }
const columns: ColumnsType<Row> = [ useEffect(() => {
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.redeemNo) === initialRedeemNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialRedeemNo, loading]);
const baseColumns: ColumnsType<Row> = [
{ {
title: '核销号', title: '核销号',
dataIndex: 'redeemNo', dataIndex: 'redeemNo',
width: 200, width: 200,
render: (v, row) => ( render: (v, row) => (
<span> <span>
{v} <AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? ( {row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}> <Tag color="orange" style={{ marginLeft: 6 }}>
@@ -112,9 +137,12 @@ export default function RedeemRecordsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-records', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> {settingsModal}
<AdminListHeader title="核销记录" settings={settingsButton} />
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -154,7 +182,7 @@ export default function RedeemRecordsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1300 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+15 -12
View File
@@ -12,6 +12,9 @@ import {
fmtTime, fmtTime,
} from '../lib/constants'; } from '../lib/constants';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type Row = { type Row = {
id: string; id: string;
@@ -58,7 +61,7 @@ export default function ResourcesPage() {
void reload(); void reload();
}, []); }, []);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: 'ID', dataIndex: 'id', width: 80 }, { title: 'ID', dataIndex: 'id', width: 80 },
{ {
title: '归属', title: '归属',
@@ -84,8 +87,8 @@ export default function ResourcesPage() {
</a> </a>
), ),
}, },
{ title: 'URL', dataIndex: 'url', ellipsis: true }, { title: 'URL', dataIndex: 'url' },
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 }, { title: 'OSS Key', dataIndex: 'ossKey', width: 160 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '排序', dataIndex: 'sortOrder', width: 60 },
{ {
title: '状态', title: '状态',
@@ -114,16 +117,16 @@ export default function ResourcesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('resources', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
OSS title="OSS 资源库"
</Typography.Title> settings={settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}> actions={<Button type="primary" onClick={() => setCreateOpen(true)}></Button>}
/>
</Button>
</Space>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -170,7 +173,7 @@ export default function ResourcesPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+33 -20
View File
@@ -6,6 +6,10 @@ import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type StoreBrief = { id: string; name: string; status: string; cityName?: string }; type StoreBrief = { id: string; name: string; status: string; cityName?: string };
@@ -74,14 +78,21 @@ export default function StoreAccountsPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '姓名', title: '姓名',
dataIndex: 'name', dataIndex: 'name',
width: 120, width: 120,
render: (v, row) => ( render: (v, row) => (
<Space size={4}> <Space size={4}>
<span>{v}</span> <AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null} {row.isTest ? <Tag color="orange"></Tag> : null}
</Space> </Space>
), ),
@@ -138,25 +149,27 @@ export default function StoreAccountsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Space direction="vertical" size={0}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="门店账户"
<Typography.Text type="secondary"> settings={settingsButton}
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
</Typography.Text> actions={
</Space> <Button
<Button type="primary"
type="primary" onClick={() => {
onClick={() => { void loadStores();
void loadStores(); setCreateOpen(true);
setCreateOpen(true); }}
}} >
>
</Button>
</Button> }
</Space> />
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -185,7 +198,7 @@ export default function StoreAccountsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+108 -24
View File
@@ -30,6 +30,10 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
kind: StoreSettlementKind; kind: StoreSettlementKind;
@@ -44,6 +48,11 @@ type Row = {
settlementRate?: number | null; settlementRate?: number | null;
payoutCount?: number | null; payoutCount?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string | null }; store?: { id: string; name: string; cityName: string; phone?: string | null };
bankAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
}; };
type StoreOption = { id: string; name: string; phone: string }; type StoreOption = { id: string; name: string; phone: string };
@@ -55,6 +64,11 @@ const STATUS_COLORS: Record<string, string> = {
REJECTED: 'red', REJECTED: 'red',
}; };
function settlementStatusColor(kind: StoreSettlementKind, status: string) {
if (status === 'PAID') return kind === 'WITHDRAW' ? 'cyan' : 'green';
return STATUS_COLORS[status] || 'default';
}
const KIND_COLORS: Record<StoreSettlementKind, string> = { const KIND_COLORS: Record<StoreSettlementKind, string> = {
T1_BILL: 'blue', T1_BILL: 'blue',
WITHDRAW: 'purple', WITHDRAW: 'purple',
@@ -63,10 +77,15 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
export default function StoreBillsPage() { export default function StoreBillsPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : ''; const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || '';
const initialStatus =
searchParams.get('status')?.trim() ||
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
const [form] = Form.useForm(); const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({ const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind, kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '', status: initialStatus,
storeId: initialStoreId,
}); });
const [stores, setStores] = useState<StoreOption[]>([]); const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -97,8 +116,9 @@ export default function StoreBillsPage() {
form.setFieldsValue({ form.setFieldsValue({
kind: filters.kind || undefined, kind: filters.kind || undefined,
status: filters.status || undefined, status: filters.status || undefined,
storeId: filters.storeId || undefined,
}); });
}, [filters.kind, filters.status, form]); }, [filters.kind, filters.status, filters.storeId, form]);
useEffect(() => { useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -112,14 +132,30 @@ export default function StoreBillsPage() {
}, []); }, []);
function confirmPay(ids: string[], amountHint?: number) { function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({ Modal.confirm({
title: '确认打款?', title: '确认打款?',
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`, content: (
<div>
<div>
{ids.length} T+1
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款', okText: '确认打款',
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) { if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' }); await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else { } else {
setBatchLoading(true); setBatchLoading(true);
try { try {
@@ -235,23 +271,24 @@ export default function StoreBillsPage() {
); );
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0); const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '类型', title: '类型',
dataIndex: 'kind', dataIndex: 'kind',
width: 100, width: 100,
render: (k: StoreSettlementKind) => ( render: (k: StoreSettlementKind) => (
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag> <Tag color={KIND_COLORS[k]} style={{ minWidth: 72, textAlign: 'center' }}>
{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}
</Tag>
), ),
}, },
{ {
title: '单号', title: '单号',
dataIndex: 'billNo', dataIndex: 'billNo',
width: 180, width: 180,
ellipsis: true,
render: (v, row) => ( render: (v, row) => (
<Space> <Space>
<span>{v}</span> <AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
{row.overdue ? <Tag color="magenta"></Tag> : null} {row.overdue ? <Tag color="magenta"></Tag> : null}
</Space> </Space>
), ),
@@ -262,9 +299,21 @@ export default function StoreBillsPage() {
width: 160, width: 160,
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)), render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
}, },
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true }, { title: '门店', dataIndex: ['store', 'name'], width: 140 },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, { title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{
title: '收款户名',
width: 100,
render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
},
{
title: '收款账号',
width: 140,
render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
},
{ {
title: '笔数', title: '笔数',
width: 80, width: 80,
@@ -282,7 +331,9 @@ export default function StoreBillsPage() {
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 100,
render: (s: string, row) => ( render: (s: string, row) => (
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag> <Tag color={settlementStatusColor(row.kind, s)}>
{storeSettlementStatusLabel(row.kind, s)}
</Tag>
), ),
}, },
{ {
@@ -326,21 +377,28 @@ export default function StoreBillsPage() {
} }
| undefined; | undefined;
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}> <AdminListHeader
title="门店账单"
</Typography.Title> settings={settingsButton}
<Typography.Text type="secondary"> description={
T+1 沿 <>
</Typography.Text> T+1 沿
{overdueSummary && overdueSummary.overdueCount > 0 ? ( = T+1 =
<Typography.Text type="danger"> {overdueSummary && overdueSummary.overdueCount > 0 ? (
{overdueSummary.overdueCount} {overdueSummary.pendingCount} <div>
</Typography.Text> <Typography.Text type="danger">
) : null} {overdueSummary.overdueCount} {overdueSummary.pendingCount}
</Space> </Typography.Text>
</div>
) : null}
</>
}
/>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
@@ -359,6 +417,7 @@ export default function StoreBillsPage() {
initialValues={{ initialValues={{
kind: filters.kind || undefined, kind: filters.kind || undefined,
status: filters.status || undefined, status: filters.status || undefined,
storeId: filters.storeId || undefined,
}} }}
onFinish={(v: { onFinish={(v: {
kind?: string; kind?: string;
@@ -461,7 +520,7 @@ export default function StoreBillsPage() {
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'), disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
}), }),
}} }}
scroll={{ x: 1300 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -496,7 +555,32 @@ export default function StoreBillsPage() {
<Descriptions.Item label="打款时间"> <Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions> </Descriptions>
{(detail.storeAccount as {
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
} | null) ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}> <Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title> </Typography.Title>
@@ -3,7 +3,11 @@ import {
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CategoryNode = { type CategoryNode = {
id: string; id: string;
@@ -38,7 +42,9 @@ export default function StoreCategoriesPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FlatRow | null>(null); const [editing, setEditing] = useState<FlatRow | null>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const canDelete = (profile?.permissionKeys ?? []).includes('store_categories_delete');
const rows = useMemo(() => flattenTree(tree), [tree]); const rows = useMemo(() => flattenTree(tree), [tree]);
const rootOptions = useMemo( const rootOptions = useMemo(
@@ -60,6 +66,7 @@ export default function StoreCategoriesPage() {
useEffect(() => { useEffect(() => {
void reload(); void reload();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
function openCreate(parentId?: string) { function openCreate(parentId?: string) {
@@ -112,7 +119,7 @@ export default function StoreCategoriesPage() {
void reload(); void reload();
} }
const columns: ColumnsType<FlatRow> = [ const baseColumns: ColumnsType<FlatRow> = [
{ {
title: '层级', title: '层级',
dataIndex: 'level', dataIndex: 'level',
@@ -125,7 +132,7 @@ export default function StoreCategoriesPage() {
render: (name, row) => ( render: (name, row) => (
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}> <span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
{row.level === 2 ? `${row.parentName || ''} / ` : ''} {row.level === 2 ? `${row.parentName || ''} / ` : ''}
{name} <AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</span> </span>
), ),
}, },
@@ -141,48 +148,59 @@ export default function StoreCategoriesPage() {
}, },
{ {
title: '操作', title: '操作',
width: 220, width: canDelete ? 220 : 160,
render: (_, row) => ( render: (_, row) => (
<Space wrap> <Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button> <Button type="link" size="small" onClick={() => openEdit(row)}></Button>
{row.level === 1 ? ( {row.level === 1 ? (
<Button type="link" size="small" onClick={() => openCreate(row.id)}></Button> <Button type="link" size="small" onClick={() => openCreate(row.id)}></Button>
) : null} ) : null}
<Popconfirm {canDelete ? (
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'} <Popconfirm
onConfirm={async () => { title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' }); onConfirm={async () => {
message.success('已处理'); await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
void reload(); message.success('已处理');
}} void reload();
> }}
<Button type="link" size="small" danger></Button> >
</Popconfirm> <Button type="link" size="small" danger></Button>
</Popconfirm>
) : null}
</Space> </Space>
), ),
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-categories', baseColumns, {
page: 1,
pageSize: rows.length || 20,
});
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Space direction="vertical" size={0}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="门店分类"
<Typography.Text type="secondary">/宿/ </Typography.Text> settings={settingsButton}
</Space> description="两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择"
<Space> actions={
<Button <>
onClick={async () => { {canDelete ? (
await request('/admin/store-categories/ensure-defaults', { method: 'POST' }); <Button
message.success('已同步默认分类'); onClick={async () => {
void reload(); await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
}} message.success('已同步默认分类');
> void reload();
}}
</Button> >
<Button type="primary" onClick={() => openCreate()}></Button>
</Space> </Button>
</Space> ) : null}
<Button type="primary" onClick={() => openCreate()}></Button>
</>
}
/>
<Table <Table
rowKey="id" rowKey="id"
+34 -10
View File
@@ -12,6 +12,10 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -80,14 +84,14 @@ export default function StoreLogsPage() {
form.setFieldsValue(filters); form.setFieldsValue(filters);
}, [form, filters]); }, [form, filters]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '门店', width: 180, ellipsis: true, title: '门店', width: 180,
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />, render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
}, },
{ {
title: '账号', width: 150, ellipsis: true, title: '账号', width: 150,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.accountName} primary={r.accountName}
@@ -101,7 +105,23 @@ export default function StoreLogsPage() {
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag> <Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 160 }, {
title: '事件',
dataIndex: 'eventName',
width: 160,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
const parsed = parseCompositeId(row.id);
if (!parsed) return;
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '来源', dataIndex: 'source', width: 100, title: '来源', dataIndex: 'source', width: 100,
render: (v: Row['source']) => SOURCE_LABELS[v] || v, render: (v: Row['source']) => SOURCE_LABELS[v] || v,
@@ -111,7 +131,7 @@ export default function StoreLogsPage() {
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
}, },
{ {
title: '摘要', ellipsis: true, title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson), render: (_, r) => summarizeExtra(r.extraJson),
}, },
{ {
@@ -127,12 +147,16 @@ export default function StoreLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-stores', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> {settingsModal}
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}> <AdminListHeader
/ title="商户日志"
</Typography.Paragraph> settings={settingsButton}
description="门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。"
/>
<Segmented <Segmented
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
@@ -164,7 +188,7 @@ export default function StoreLogsPage() {
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item> <Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} <Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && ( {detail && (
+31 -8
View File
@@ -7,6 +7,10 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string; id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
@@ -40,8 +44,23 @@ export default function StoreMediaPage() {
setStores(res.items); setStores(res.items);
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '门店', dataIndex: ['store', 'name'], width: 140 }, {
title: '门店',
dataIndex: ['store', 'name'],
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={() => {
setEditing(row);
editForm.setFieldsValue(row);
setEditOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> }, { title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
{ {
title: '预览', dataIndex: 'url', width: 100, title: '预览', dataIndex: 'url', width: 100,
@@ -49,7 +68,7 @@ export default function StoreMediaPage() {
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} /> ? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
: <a href={url} target="_blank" rel="noreferrer"></a>, : <a href={url} target="_blank" rel="noreferrer"></a>,
}, },
{ title: 'URL', dataIndex: 'url', ellipsis: true }, { title: 'URL', dataIndex: 'url' },
{ title: '排序', dataIndex: 'sortOrder', width: 70 }, { title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -73,12 +92,16 @@ export default function StoreMediaPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-media', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <AdminListHeader
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button> title="门店资源"
</Space> settings={settingsButton}
actions={<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button>}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item> <Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
<Form.Item name="mediaType" label="类型"> <Form.Item name="mediaType" label="类型">
@@ -86,7 +109,7 @@ export default function StoreMediaPage() {
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }} <Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields(); const v = await createForm.validateFields();
@@ -3,29 +3,38 @@ import { useSearchParams } from 'react-router-dom';
import { import {
Badge, Badge,
Button, Button,
Descriptions,
Drawer, Drawer,
Image, Image,
Input, Input,
Modal, Modal,
Space, Space,
Table, Table,
Tabs,
Tag, Tag,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { import type {
StorePackageAuditDetailDto, StoreInfoChangeFieldDiff,
StoreInfoChangeRequestDto,
StoreInfoChangeStatus,
StorePackageAuditSummaryDto, StorePackageAuditSummaryDto,
StorePackageChangeRequestDto, StorePackageChangeRequestDto,
StorePackageChangeStatus, StorePackageChangeStatus,
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types'; import {
STORE_INFO_CHANGE_STATUS_LABELS,
STORE_INFO_CHANGEABLE_FIELD_LABELS,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events'; import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = { const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核', PENDING: '待审核',
@@ -33,299 +42,113 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
REJECTED: '已驳回', REJECTED: '已驳回',
}; };
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) { function fmtFieldValue(field: string, v: unknown): string {
const name = String(pkg.name ?? '').trim(); if (v == null || String(v).trim() === '') return '(空)';
return name ? `name:${name}` : `idx:${index}`; if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
} return String(v);
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 }> = []; if (field === 'envPhotoUrls' && Array.isArray(v)) {
let i = 0; return `${v.length}`;
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++] }); if (field === 'coverUrl') return '见对比图';
while (j < n) raw.push({ type: 'insert', text: b[j++] }); return String(v);
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 InfoChangeImageDiff({
function fieldChanges( field,
live: StorePackageItemDto | StorePackageViewDto, live,
proposed: StorePackageItemDto | StorePackageViewDto, proposed,
): FieldChange[] { imageSize = 72,
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]));
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
const rows: Array<{
key: string;
change: 'added' | 'removed' | 'changed' | 'unchanged';
live?: StorePackageViewDto;
proposed?: StorePackageItemDto;
changes?: FieldChange[];
}> = [];
for (const key of keys) {
const l = liveMap.get(key);
const p = proposedMap.get(key);
if (l && !p) {
rows.push({ key, change: 'removed', live: l });
} else if (!l && p) {
rows.push({ key, change: 'added', proposed: p });
} else if (l && p) {
const changed =
l.price !== p.price ||
l.dishes !== p.dishes ||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
(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;
}
const CHANGE_LABELS = {
added: { text: '新增', color: 'green' },
removed: { text: '删除', color: 'red' },
changed: { text: '变更', color: 'orange' },
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; field: string;
pkg: StorePackageItemDto | StorePackageViewDto; live: unknown;
change?: keyof typeof CHANGE_LABELS; proposed: unknown;
changes?: FieldChange[]; imageSize?: number;
}) { }) {
const images = normalizeStorePackageImageUrls(pkg); const liveUrls =
const meta = change ? CHANGE_LABELS[change] : null; field === 'coverUrl'
? [String(live || '').trim()].filter(Boolean)
: Array.isArray(live)
? live.map((u) => String(u || '').trim()).filter(Boolean)
: [];
const proposedUrls =
field === 'coverUrl'
? [String(proposed || '').trim()].filter(Boolean)
: Array.isArray(proposed)
? proposed.map((u) => String(u || '').trim()).filter(Boolean)
: [];
return ( return (
<div <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
style={{ <div>
marginBottom: 12, <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
padding: 12,
border: '1px solid #f0f0f0', </Typography.Text>
borderRadius: 8, {liveUrls.length ? (
background: '#fafafa', <Image.PreviewGroup>
}} <Space wrap size={8}>
> {liveUrls.map((url) => (
<Space style={{ marginBottom: 8 }} wrap> <Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
{title ? ( ))}
<Typography.Text type="secondary">{title}</Typography.Text> </Space>
) : null} </Image.PreviewGroup>
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null} ) : (
</Space> <Typography.Text type="secondary"></Typography.Text>
<div style={{ marginBottom: 8 }}> )}
<strong>{pkg.name}</strong> </div>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span> <div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
</Typography.Text>
{proposedUrls.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{proposedUrls.map((url) => (
<Image key={`new-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div> </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> </div>
); );
} }
export default function StorePackageAuditsPage() { function InfoChangeAuditPanel({
initialRequestId,
}: {
initialRequestId?: string | null;
}) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]); const [items, setItems] = useState<StoreInfoChangeRequestDto[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('PENDING'); const [status, setStatus] = useState<string>('PENDING');
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<(StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }) | null>(null);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false); const [infoFullscreen, setInfoFullscreen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
async function reload(nextPage = page, nextStatus = status) { async function reload(nextPage = page, nextStatus = status) {
setLoading(true); setLoading(true);
try { try {
const qs = new URLSearchParams({ const qs = new URLSearchParams({ page: String(nextPage), pageSize: '20' });
page: String(nextPage),
pageSize: '20',
});
if (nextStatus) qs.set('status', nextStatus); if (nextStatus) qs.set('status', nextStatus);
const [data, summary] = await Promise.all([ const [data, summary] = await Promise.all([
request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`), request<{ items: StoreInfoChangeRequestDto[]; total: number; page?: number }>(
request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'), `/admin/store-info-change-requests?${qs}`,
),
request<{ pendingCount: number }>('/admin/store-info-change-requests/summary'),
]); ]);
setItems(data.items); setItems(data.items);
setTotal(data.total); setTotal(data.total);
setPage(data.page); setPage(data.page ?? nextPage);
setPendingCount(summary.pendingCount ?? 0); setPendingCount(summary.pendingCount ?? 0);
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '加载失败'); message.error(e instanceof Error ? e.message : '加载失败');
@@ -338,11 +161,8 @@ export default function StorePackageAuditsPage() {
void reload(1, status); void reload(1, status);
}, [status]); }, [status]);
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
const [searchParams] = useSearchParams();
useEffect(() => { useEffect(() => {
const rid = searchParams.get('requestId'); if (initialRequestId) void openDetail(initialRequestId);
if (rid) void openDetail(rid);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -351,7 +171,9 @@ export default function StorePackageAuditsPage() {
setDetailLoading(true); setDetailLoading(true);
setDetail(null); setDetail(null);
try { try {
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${id}`); const data = await request<StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }>(
`/admin/store-info-change-requests/${id}`,
);
setDetail(data); setDetail(data);
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '加载详情失败'); message.error(e instanceof Error ? e.message : '加载详情失败');
@@ -363,7 +185,7 @@ export default function StorePackageAuditsPage() {
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) { async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
try { try {
await request(`/admin/store-package-audits/${id}/audit`, { await request(`/admin/store-info-change-requests/${id}/audit`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify( body: JSON.stringify(
action === 'REJECT' ? { action, rejectReason: reason } : { action }, action === 'REJECT' ? { action, rejectReason: reason } : { action },
@@ -378,30 +200,36 @@ export default function StorePackageAuditsPage() {
} }
} }
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : []; const columns: ColumnsType<StoreInfoChangeRequestDto> = [
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 },
{ {
title: '状态', title: '门店',
dataIndex: 'status', dataIndex: 'storeName',
render: (v: StorePackageChangeRequestDto['status']) => ( render: (_, row) => (
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag> <AdminPrimaryLink onClick={() => void openDetail(row.id)}>
{row.storeName || row.storeId}
</AdminPrimaryLink>
), ),
}, },
{ {
title: '套餐数', title: '状态',
render: (_, row) => row.packages?.length ?? 0, dataIndex: 'status',
render: (v: StoreInfoChangeStatus) => (
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
),
},
{
title: '变更字段',
render: (_, row) =>
row.changedFields?.length
? row.changedFields.map((f) => (
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
))
: '—',
}, },
{ {
title: '提交方', title: '提交方',
render: (_, row) => (row.submitterType === 'PARTNER' ? '合伙人' : '门店'), render: (_, row) =>
row.submitterType === 'PARTNER' ? '合伙人' : row.submitterType === 'SHOP' ? '门店' : '总部',
}, },
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) }, { title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
{ {
@@ -409,7 +237,7 @@ export default function StorePackageAuditsPage() {
render: (_, row) => ( render: (_, row) => (
<Space> <Space>
<Button type="link" onClick={() => void openDetail(row.id)}> <Button type="link" onClick={() => void openDetail(row.id)}>
</Button> </Button>
{row.status === 'PENDING' ? ( {row.status === 'PENDING' ? (
<> <>
@@ -438,16 +266,19 @@ export default function StorePackageAuditsPage() {
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}> <Button
key={s || 'all'}
type={status === s ? 'primary' : 'default'}
onClick={() => setStatus(s)}
>
{s === 'PENDING' ? ( {s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}> <Badge count={pendingCount} size="small" offset={[8, -2]}>
{HQ_PACKAGE_STATUS_LABELS.PENDING} {STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
</Badge> </Badge>
) : s ? ( ) : s ? (
HQ_PACKAGE_STATUS_LABELS[s] STORE_INFO_CHANGE_STATUS_LABELS[s]
) : ( ) : (
'全部' '全部'
)} )}
@@ -468,26 +299,34 @@ export default function StorePackageAuditsPage() {
/> />
<Drawer <Drawer
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'} title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={880} width={infoFullscreen ? '100%' : 680}
open={detailOpen} open={detailOpen}
onClose={() => setDetailOpen(false)} onClose={() => {
setDetailOpen(false);
setInfoFullscreen(false);
}}
extra={ extra={
detail?.status === 'PENDING' ? ( <Space>
<Space> <Button onClick={() => setInfoFullscreen((v) => !v)}>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button> {infoFullscreen ? '退出全屏' : '全屏查看'}
<Button </Button>
danger {detail?.status === 'PENDING' ? (
onClick={() => { <>
setActiveId(detail.id); <Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
setRejectReason(''); <Button
setRejectOpen(true); danger
}} onClick={() => {
> setActiveId(detail.id);
setRejectReason('');
</Button> setRejectOpen(true);
</Space> }}
) : null >
</Button>
</>
) : null}
</Space>
} }
> >
{detailLoading ? ( {detailLoading ? (
@@ -495,66 +334,296 @@ export default function StorePackageAuditsPage() {
) : detail ? ( ) : detail ? (
<> <>
<Space style={{ marginBottom: 16 }} wrap> <Space style={{ marginBottom: 16 }} wrap>
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag> <Tag>{STORE_INFO_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)} {detail.submitterType === 'PARTNER' ? '合伙人' : detail.submitterType === 'SHOP' ? '门店' : '总部'} · {fmtTime(detail.createdAt)}
</Typography.Text> </Typography.Text>
</Space> </Space>
{detail.rejectReason ? ( {detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph> <Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null} ) : null}
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}> {detail.diffs && detail.diffs.length ? (
<Typography.Text type="secondary"> <Descriptions column={1} bordered size="small">
线 {detail.livePackages?.length ?? 0} · {detail.packages?.length ?? 0} {detail.diffs.map((d) => (
</Typography.Text> <Descriptions.Item
<Space wrap> key={d.field}
<Tag color="green"> {addedCount}</Tag> label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
<Tag color="red"> {removedCount}</Tag> >
<Tag color="orange"> {changedCount}</Tag> {d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
{unchangedCount ? <Tag> {unchangedCount}</Tag> : null} <InfoChangeImageDiff
</Space> field={d.field}
</Space> live={d.live}
<div className="admin-package-audit-cols"> proposed={d.proposed}
<div className="admin-package-audit-col"> imageSize={infoFullscreen ? 160 : 72}
<Typography.Title level={5} style={{ marginTop: 0 }}> />
线 ) : (
</Typography.Title> <span>
{(detail.livePackages ?? []).length ? ( <Typography.Text delete type="secondary">
(detail.livePackages ?? []).map((pkg, index) => ( {fmtFieldValue(d.field, d.live)}
<PackageDetailCard </Typography.Text>
key={`live-${packageKey(pkg, index)}`} <Typography.Text type="secondary"> </Typography.Text>
title={`套餐 ${index + 1}`} <Typography.Text strong>
pkg={pkg} {fmtFieldValue(d.field, d.proposed)}
change={changeByKey.get(packageKey(pkg, index))} </Typography.Text>
/> </span>
)) )}
) : ( </Descriptions.Item>
<Typography.Text type="secondary">线</Typography.Text> ))}
)} </Descriptions>
</div> ) : (
<div className="admin-package-audit-col"> <Typography.Text type="secondary"></Typography.Text>
<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} ) : null}
</Drawer> </Drawer>
<Modal
title="驳回信息变更"
open={rejectOpen}
onCancel={() => setRejectOpen(false)}
onOk={() => {
if (!activeId) return;
if (!rejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
void audit(activeId, 'REJECT', rejectReason.trim());
setRejectOpen(false);
}}
>
<Input.TextArea
rows={3}
value={rejectReason}
placeholder="驳回原因"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</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);
const [detailOpen, setDetailOpen] = useState(false);
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
const [packageFullscreen, setPackageFullscreen] = useState(false);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
try {
const qs = new URLSearchParams({
page: String(nextPage),
pageSize: '20',
});
if (nextStatus) qs.set('status', nextStatus);
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 {
setLoading(false);
}
}
useEffect(() => {
void reload(1, status);
}, [status]);
const [searchParams] = useSearchParams();
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
const [activeTab, setActiveTab] = useState<string>(initialTab);
const infoRequestId = searchParams.get('infoRequestId');
useEffect(() => {
const rid = searchParams.get('requestId');
if (rid) {
setActiveRequestId(rid);
setDetailOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function openDetail(id: string) {
setActiveRequestId(id);
setDrawerTitle('套餐变更详情');
setDetailOpen(true);
}
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
try {
await request(`/admin/store-package-audits/${id}/audit`, {
method: 'PUT',
body: JSON.stringify(
action === 'REJECT' ? { action, rejectReason: reason } : { action },
),
});
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
setDetailOpen(false);
notifyPackageAuditChanged();
void reload(page, status);
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
}
}
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
{
title: '门店',
dataIndex: 'storeName',
render: (_, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
{row.storeName || row.storeId}
</AdminPrimaryLink>
),
},
{
title: '状态',
dataIndex: 'status',
render: (v: StorePackageChangeRequestDto['status']) => (
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
),
},
{
title: '套餐数',
render: (_, row) => row.packages?.length ?? 0,
},
{
title: '提交方',
render: (_, row) => (row.submitterType === 'PARTNER' ? '合伙人' : '门店'),
},
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
{
title: '操作',
render: (_, row) => (
<Space>
<Button type="link" onClick={() => openDetail(row.id)}>
</Button>
{row.status === 'PENDING' ? (
<>
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
</Button>
<Button
type="link"
danger
onClick={() => {
setActiveId(row.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</>
) : (
row.rejectReason || null
)}
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-package-audits', baseColumns, {
page,
pageSize: 20,
});
return (
<div>
{settingsModal}
<AdminListHeader title="审核通知" settings={settingsButton} />
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'package',
label: '套餐审核',
children: (
<>
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
{s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{HQ_PACKAGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
HQ_PACKAGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button>
))}
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={items}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => void reload(p, status),
}}
/>
</>
),
},
{
key: 'info',
label: '信息变更',
children: <InfoChangeAuditPanel initialRequestId={infoRequestId} />,
},
]}
/>
<Drawer
title={drawerTitle}
width={packageFullscreen ? '100%' : 880}
open={detailOpen}
onClose={() => {
setDetailOpen(false);
setPackageFullscreen(false);
}}
destroyOnClose
extra={
<Button onClick={() => setPackageFullscreen((v) => !v)}>
{packageFullscreen ? '退出全屏' : '全屏查看'}
</Button>
}
>
{activeRequestId ? (
<StorePackageAuditPanel
requestId={activeRequestId}
fullscreen={packageFullscreen}
onAudited={() => {
setDetailOpen(false);
void reload(page, status);
}}
onDetailLoaded={(d) => {
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
}}
/>
) : null}
</Drawer>
<Modal <Modal
title="驳回套餐变更" title="驳回套餐变更"
open={rejectOpen} open={rejectOpen}
@@ -579,3 +648,4 @@ export default function StorePackageAuditsPage() {
</div> </div>
); );
} }
+31 -7
View File
@@ -1,9 +1,13 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Form, Input, Table, Typography } from 'antd'; import { Button, Form, Input, Select, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { fmtTime } from '../lib/constants'; import { fmtTime, ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { request, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type Row = { type Row = {
id: string; id: string;
@@ -20,22 +24,30 @@ export default function StoreRatingsPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialStoreId = searchParams.get('storeId') || ''; const initialStoreId = searchParams.get('storeId') || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [cities, setCities] = useState<{ id: string; name: string }[]>([]);
const [filters, setFilters] = useState<Record<string, string>>({ const [filters, setFilters] = useState<Record<string, string>>({
...(initialStoreId ? { storeId: initialStoreId } : {}), ...(initialStoreId ? { storeId: initialStoreId } : {}),
}); });
useEffect(() => {
request<Paginated<{ id: string; name: string }>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items))
.catch(() => {});
}, []);
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/store-ratings', '/admin/store-ratings',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo); if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
return qs; return qs;
}, },
[filters], [filters],
); );
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 }, { title: '核销号', dataIndex: 'redeemNo', width: 180 },
{ {
title: '门店', title: '门店',
@@ -53,11 +65,12 @@ export default function StoreRatingsPage() {
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime }, { title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-ratings', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4} style={{ marginTop: 0 }}> {settingsModal}
<AdminListHeader title="门店评价" settings={settingsButton} />
</Typography.Title>
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
@@ -66,11 +79,22 @@ export default function StoreRatingsPage() {
onFinish={(v) => { onFinish={(v) => {
setFilters({ setFilters({
storeId: v.storeId || '', storeId: v.storeId || '',
cityId: v.cityId || '',
redeemNo: v.redeemNo || '', redeemNo: v.redeemNo || '',
}); });
setPage(1); setPage(1);
}} }}
> >
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="storeId" label="门店ID"> <Form.Item name="storeId" label="门店ID">
<Input allowClear placeholder="storeId" /> <Input allowClear placeholder="storeId" />
</Form.Item> </Form.Item>
@@ -20,6 +20,10 @@ import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -147,14 +151,14 @@ export default function StoreWithdrawalsPage() {
}); });
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ {
title: '提现单号', title: '提现单号',
dataIndex: 'withdrawNo', dataIndex: 'withdrawNo',
width: 180, width: 180,
render: (v, row) => ( render: (v, row) => (
<Space> <Space>
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link> <AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.overdue ? <Tag color="magenta"></Tag> : null} {row.overdue ? <Tag color="magenta"></Tag> : null}
</Space> </Space>
), ),
@@ -229,11 +233,12 @@ export default function StoreWithdrawalsPage() {
} }
| undefined; | undefined;
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-withdrawals', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4} style={{ marginTop: 0 }}> {settingsModal}
<AdminListHeader title="门店提现审" settings={settingsButton} />
</Typography.Title>
{overdueSummary ? ( {overdueSummary ? (
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
{overdueSummary.pendingCount} {overdueSummary.pendingCount}
@@ -299,7 +304,7 @@ export default function StoreWithdrawalsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')} rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
pagination={{ pagination={{
current: page, current: page,
+213 -38
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Badge,
Button, Button,
Checkbox, Checkbox,
Descriptions, Descriptions,
@@ -38,6 +39,9 @@ import {
type StoreCreateForm, type StoreCreateForm,
} from '../lib/storeCreate'; } from '../lib/storeCreate';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { resolveRegionBinding } from '../lib/china-region'; import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader'; import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
@@ -46,6 +50,9 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal';
import AdminStorePackagesSection, { import AdminStorePackagesSection, {
type AdminStorePackagesHandle, type AdminStorePackagesHandle,
} from '../components/AdminStorePackagesSection'; } from '../components/AdminStorePackagesSection';
import StorePackageAuditPanel, {
auditStorePackageRequest,
} from '../components/StorePackageAuditPanel';
const CREATE_STEPS = [ const CREATE_STEPS = [
{ title: '基本信息' }, { title: '基本信息' },
@@ -192,6 +199,11 @@ type StoreRow = {
visibilityPhones?: string[]; visibilityPhones?: string[];
/** 该门店当前待审核套餐变更的 requestId(无则为空) */ /** 该门店当前待审核套餐变更的 requestId(无则为空) */
pendingPackageAuditId?: string | null; pendingPackageAuditId?: string | null;
/** 该门店当前待审核信息变更的 requestId(无则为空) */
pendingInfoChangeId?: string | null;
/** 累计核销好客权益(券面额合计) */
totalRedeemedBenefitAmount?: number;
contactPhone?: string | null;
isTest?: boolean; isTest?: boolean;
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null }; partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
@@ -240,6 +252,8 @@ export default function StoresPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId') ?? ''; const initialCityId = searchParams.get('cityId') ?? '';
const initialPartnerId = searchParams.get('partnerId') ?? ''; const initialPartnerId = searchParams.get('partnerId') ?? '';
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
const initialStoreId = searchParams.get('storeId') ?? '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>(); const [createForm] = Form.useForm<StoreCreateForm>();
@@ -247,6 +261,7 @@ export default function StoresPage() {
const init: Record<string, string | boolean> = {}; const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId; if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId; if (initialPartnerId) init.partnerId = initialPartnerId;
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
return init; return init;
}); });
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
@@ -268,7 +283,11 @@ export default function StoresPage() {
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]); const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [detailTab, setDetailTab] = useState('basic');
const packagesRef = useRef<AdminStorePackagesHandle>(null); const packagesRef = useRef<AdminStorePackagesHandle>(null);
const [packageRejectOpen, setPackageRejectOpen] = useState(false);
const [packageRejectReason, setPackageRejectReason] = useState('');
const [packageAuditing, setPackageAuditing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [auditing, setAuditing] = useState(false); const [auditing, setAuditing] = useState(false);
@@ -284,6 +303,7 @@ export default function StoresPage() {
const [optionsLoading, setOptionsLoading] = useState(false); const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null); const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
const deepLinkStoreOpenedRef = useRef(false);
useEffect(() => { useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -294,6 +314,12 @@ export default function StoresPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
useEffect(() => {
if (initialAuditStatus) {
form.setFieldsValue({ auditStatus: initialAuditStatus });
}
}, [form, initialAuditStatus]);
useEffect(() => { useEffect(() => {
const cityId = searchParams.get('cityId') ?? ''; const cityId = searchParams.get('cityId') ?? '';
const partnerId = searchParams.get('partnerId') ?? ''; const partnerId = searchParams.get('partnerId') ?? '';
@@ -346,13 +372,18 @@ export default function StoresPage() {
.map((n) => ({ value: n.id, label: n.name })); .map((n) => ({ value: n.id, label: n.name }));
}, [categoryTree, editCategoryParentId]); }, [categoryTree, editCategoryParentId]);
async function openStoreDetail(row: StoreRow) { async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
const [d, cats] = await Promise.all([ const [d, cats] = await Promise.all([
request<Record<string, unknown>>(`/admin/stores/${row.id}`), request<Record<string, unknown>>(`/admin/stores/${row.id}`),
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]), request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
]); ]);
setCategoryTree(Array.isArray(cats) ? cats : []); setCategoryTree(Array.isArray(cats) ? cats : []);
setDetail(d); setDetail({
...d,
pendingPackageAuditId: row.pendingPackageAuditId ?? d.pendingPackageAuditId,
pendingInfoChangeId: row.pendingInfoChangeId ?? d.pendingInfoChangeId,
});
setDetailTab(opts?.tab || 'basic');
const category = d.category && typeof d.category === 'object' const category = d.category && typeof d.category === 'object'
? (d.category as { id?: string; parentId?: string | null }) ? (d.category as { id?: string; parentId?: string | null })
: null; : null;
@@ -439,6 +470,19 @@ export default function StoresPage() {
setDrawerOpen(true); setDrawerOpen(true);
} }
useEffect(() => {
if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return;
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
if (row) {
deepLinkStoreOpenedRef.current = true;
void openStoreDetail(row);
} else if (data && (data.items?.length ?? 0) >= 0) {
// 列表无该店时仍尝试直拉详情
deepLinkStoreOpenedRef.current = true;
void openStoreDetail({ id: initialStoreId } as StoreRow);
}
}, [data, initialStoreId, loading]);
async function saveStoreDetail() { async function saveStoreDetail() {
if (!detail) return; if (!detail) return;
setSaving(true); setSaving(true);
@@ -682,47 +726,60 @@ export default function StoresPage() {
} }
} }
const columns: ColumnsType<StoreRow> = [ const baseColumns: ColumnsType<StoreRow> = [
{ {
key: 'coverUrl',
title: '封面', dataIndex: 'coverUrl', width: 72, title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—', render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
}, },
{ {
key: 'name',
title: '门店名', title: '门店名',
dataIndex: 'name', dataIndex: 'name',
width: 180, width: 180,
ellipsis: { showTitle: false },
render: (v: string, row) => { render: (v: string, row) => {
const name = v || '—'; const name = v || '—';
return ( return (
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}> <Space size={4} wrap={false}>
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}> <AdminPrimaryLink onClick={() => void openStoreDetail(row)}>{name === '—' ? '' : name}</AdminPrimaryLink>
{name}
</Typography.Text>
{row.isTest ? <Tag color="orange"></Tag> : null} {row.isTest ? <Tag color="orange"></Tag> : null}
</Space> </Space>
); );
}, },
}, },
{ {
key: 'category',
title: '分类', title: '分类',
width: 100, width: 100,
ellipsis: true,
render: (_, row) => row.category?.name || '—', render: (_, row) => row.category?.name || '—',
}, },
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true }, { key: 'cityName', title: '城市', dataIndex: 'cityName', width: 80 },
{ title: '登录号', dataIndex: 'phone', width: 120 }, { key: 'phone', title: '登录号', dataIndex: 'phone', width: 120 },
{ {
key: 'contactPhone',
title: '联系电话', title: '联系电话',
dataIndex: 'contactPhone', dataIndex: 'contactPhone',
width: 120, width: 120,
render: (v: string | null | undefined, row) => v || row.phone, render: (v: string | null | undefined, row) => v || row.phone,
}, },
{ {
key: 'status',
title: '营业状态', dataIndex: 'status', width: 90, title: '营业状态', dataIndex: 'status', width: 90,
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
}, },
{ {
key: 'totalRedeemedBenefitAmount',
title: '累计核销好客权益',
dataIndex: 'totalRedeemedBenefitAmount',
width: 150,
align: 'right',
render: (v: number | undefined) => {
const n = Number(v ?? 0);
return n > 0 ? `¥${n.toFixed(2)}` : '—';
},
},
{
key: 'auditStatus',
title: '审核', dataIndex: 'auditStatus', width: 100, title: '审核', dataIndex: 'auditStatus', width: 100,
render: (s, row) => { render: (s, row) => {
const status = s || 'APPROVED'; const status = s || 'APPROVED';
@@ -731,7 +788,7 @@ export default function StoresPage() {
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}> <Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag> <Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? ( {status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason} {row.rejectReason}
</Typography.Text> </Typography.Text>
) : null} ) : null}
@@ -740,71 +797,91 @@ export default function StoresPage() {
}, },
}, },
{ {
key: 'partner',
title: '开城合伙人', title: '开城合伙人',
dataIndex: 'partner', dataIndex: 'partner',
width: 140, width: 140,
ellipsis: { showTitle: false },
render: (partner: StoreRow['partner']) => { render: (partner: StoreRow['partner']) => {
if (!partner) return '—'; if (!partner) return '—';
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner }); return partnerOptionLabel({ id: partner.id ?? '', ...partner });
return (
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
{label}
</Typography.Text>
);
}, },
}, },
{ {
key: 'visibilityWhitelistEnabled',
title: '可见', title: '可见',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
width: 90, width: 90,
render: (v) => render: (v) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>, v ? <Tag color="orange"></Tag> : <Tag></Tag>,
}, },
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' }, { key: 'intro', title: '介绍', dataIndex: 'intro', width: 160, render: (v) => v || '—' },
{ title: '排序', dataIndex: 'sortOrder', width: 70 }, { key: 'sortOrder', title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' }, { key: 'accountName', title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { key: 'createdAt', title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
key: 'actions',
title: '操作', title: '操作',
width: 220, width: 280,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0} wrap> <Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button> <Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}></Button> <Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}></Button>
{row.pendingPackageAuditId ? ( {row.pendingPackageAuditId ? (
<Button
type="link"
size="small"
onClick={() => void openStoreDetail(row, { tab: 'packages' })}
>
</Button>
) : null}
{row.pendingInfoChangeId ? (
<> <>
<Button <Button
type="link" type="link"
size="small" size="small"
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)} onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
> >
</Button> </Button>
<Button <Button
type="link" type="link"
size="small" size="small"
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)} onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
> >
</Button> </Button>
</> </>
) : null} ) : null}
<Button
type="link"
size="small"
onClick={() => navigate(`/finance/store-bills?storeId=${row.id}`)}
>
</Button>
</Space> </Space>
), ),
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('stores', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Space direction="vertical" size={0}> <AdminListHeader
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> title="门店"
<Typography.Text type="secondary"> {data?.total ?? 0} </Typography.Text> settings={settingsButton}
</Space> description={`${data?.total ?? 0} 家门店(含合伙人录入)`}
<Button type="primary" onClick={openCreateModal}></Button> actions={
</Space> <Button type="primary" onClick={openCreateModal}></Button>
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item> <Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item> <Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
@@ -864,7 +941,7 @@ export default function StoresPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1720 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -876,7 +953,7 @@ export default function StoresPage() {
}, },
}} }}
/> />
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="门店详情" width={880} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
<Space wrap> <Space wrap>
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? ( {String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
@@ -913,6 +990,41 @@ export default function StoresPage() {
</Button> </Button>
</> </>
) : null} ) : null}
{detail.pendingPackageAuditId ? (
<>
<Button
type="primary"
ghost
loading={packageAuditing}
onClick={async () => {
setPackageAuditing(true);
try {
await auditStorePackageRequest(String(detail.pendingPackageAuditId), 'APPROVE');
message.success('套餐已通过');
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '套餐审核失败');
} finally {
setPackageAuditing(false);
}
}}
>
</Button>
<Button
danger
ghost
loading={packageAuditing}
onClick={() => {
setPackageRejectReason('');
setPackageRejectOpen(true);
}}
>
</Button>
</>
) : null}
<Select value={String(detail.status)} style={{ width: 120 }} <Select value={String(detail.status)} style={{ width: 120 }}
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => { onChange={async (status) => {
@@ -927,6 +1039,8 @@ export default function StoresPage() {
{detail && ( {detail && (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Tabs <Tabs
activeKey={detailTab}
onChange={setDetailTab}
destroyInactiveTabPane={false} destroyInactiveTabPane={false}
items={[ items={[
{ {
@@ -1175,15 +1289,76 @@ export default function StoresPage() {
}, },
{ {
key: 'packages', key: 'packages',
label: '套餐', label: detail.pendingPackageAuditId ? (
<Badge dot offset={[4, 0]}>
</Badge>
) : (
'套餐'
),
forceRender: true, forceRender: true,
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />, children: (
<>
{detail.pendingPackageAuditId ? (
<div style={{ marginBottom: 24 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<StorePackageAuditPanel
requestId={String(detail.pendingPackageAuditId)}
showActions
onAudited={() => {
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
}}
/>
</div>
) : null}
<AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />
</>
),
}, },
]} ]}
/> />
</Form> </Form>
)} )}
</Drawer> </Drawer>
<Modal
title="驳回套餐变更"
open={packageRejectOpen}
confirmLoading={packageAuditing}
onCancel={() => setPackageRejectOpen(false)}
onOk={async () => {
if (!detail?.pendingPackageAuditId) return;
if (!packageRejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
setPackageAuditing(true);
try {
await auditStorePackageRequest(
String(detail.pendingPackageAuditId),
'REJECT',
packageRejectReason.trim(),
);
message.success('套餐已驳回');
setPackageRejectOpen(false);
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '套餐驳回失败');
} finally {
setPackageAuditing(false);
}
}}
>
<Input.TextArea
rows={3}
value={packageRejectReason}
placeholder="驳回原因"
onChange={(e) => setPackageRejectReason(e.target.value)}
/>
</Modal>
<Modal <Modal
title="新建门店" title="新建门店"
open={createOpen} open={createOpen}
@@ -46,6 +46,9 @@ import { request, type HqProfile } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom'; const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程'; const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
@@ -218,8 +221,13 @@ export default function SupportTicketsPage() {
setReviewOpen(false); setReviewOpen(false);
const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500); const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500);
const ticketUrls = (detail!.attachmentUrls ?? []).filter(Boolean);
tasksForm.setFieldsValue({ tasksForm.setFieldsValue({
tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }], tasks: [{
content: summary || detail!.title,
type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType),
attachmentUrls: ticketUrls,
}],
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false', dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT, dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
}); });
@@ -364,7 +372,11 @@ export default function SupportTicketsPage() {
body: JSON.stringify({ body: JSON.stringify({
decision: 'APPROVE', decision: 'APPROVE',
note: note?.trim() || undefined, note: note?.trim() || undefined,
tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })), tasks: values.tasks.map((t) => ({
content: t.content.trim(),
type: t.type,
attachmentUrls: t.attachmentUrls?.length ? t.attachmentUrls : detail!.attachmentUrls,
})),
dispatchToWecom: !!values.dispatchToWecom, dispatchToWecom: !!values.dispatchToWecom,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined, dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}), }),
@@ -461,7 +473,7 @@ export default function SupportTicketsPage() {
} }
} }
const columns: ColumnsType<TicketRow> = [ const baseColumns: ColumnsType<TicketRow> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 }, { title: '工单号', dataIndex: 'ticketNo', width: 180 },
{ {
title: '类型', title: '类型',
@@ -494,7 +506,13 @@ export default function SupportTicketsPage() {
</Space> </Space>
), ),
}, },
{ title: '标题', dataIndex: 'title', ellipsis: true }, {
title: '标题',
dataIndex: 'title',
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(String(row.id))}>{v}</AdminPrimaryLink>
),
},
{ title: '创建人', dataIndex: 'creatorName', width: 100 }, { title: '创建人', dataIndex: 'creatorName', width: 100 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -537,8 +555,11 @@ export default function SupportTicketsPage() {
return null; return null;
})(); })();
const { columns, settingsButton, settingsModal } = useAdminListColumns('support-tickets', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -550,6 +571,7 @@ export default function SupportTicketsPage() {
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Space> <Space>
{isSuperAdmin ? ( {isSuperAdmin ? (
<> <>
@@ -610,7 +632,7 @@ export default function SupportTicketsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 900 }} scroll={{ x: 'max-content' }}
rowSelection={ rowSelection={
isSuperAdmin isSuperAdmin
? { ? {
+75 -12
View File
@@ -249,15 +249,70 @@ export default function SystemSettingsPage() {
const collapseItems = useMemo(() => { const collapseItems = useMemo(() => {
if (!meta) return []; if (!meta) return [];
return meta.groups.map((group) => ({ const SHARE_SUBGROUP_LABELS: Record<string, string> = {
key: group.key, global: '全局',
label: group.label, home: '首页',
forceRender: true, stores: '门店列表',
children: ( storeDetail: '门店详情',
<div style={{ maxWidth: 720 }}> benefit: '权益页',
{meta.fields mine: '我的',
.filter((f) => f.group === group.key) productDetail: '商品详情',
.map((f) => orderDetail: '订单详情',
};
const SHARE_SUBGROUP_ORDER = [
'global',
'home',
'stores',
'storeDetail',
'benefit',
'mine',
'productDetail',
'orderDetail',
];
return meta.groups.map((group) => {
const fields = meta.fields.filter((f) => f.group === group.key);
const hasSubgroups = fields.some((f) => f.subgroup);
let children: ReactNode;
if (group.key === 'wechat_mini_share' && hasSubgroups) {
const bySub = new Map<string, SystemConfigFieldMeta[]>();
for (const f of fields) {
const sk = f.subgroup || 'global';
if (!bySub.has(sk)) bySub.set(sk, []);
bySub.get(sk)!.push(f);
}
const orderedKeys = [
...SHARE_SUBGROUP_ORDER.filter((k) => bySub.has(k)),
...[...bySub.keys()].filter((k) => !SHARE_SUBGROUP_ORDER.includes(k)),
];
children = (
<Collapse
defaultActiveKey={[]}
items={orderedKeys.map((sk) => ({
key: sk,
label: SHARE_SUBGROUP_LABELS[sk] || sk,
forceRender: true,
children: (
<div style={{ maxWidth: 720 }}>
{(bySub.get(sk) ?? []).map((f) =>
renderField(
f,
meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
) : undefined,
),
)}
</div>
),
}))}
/>
);
} else {
children = (
<div style={{ maxWidth: 720 }}>
{fields.map((f) =>
renderField( renderField(
f, f,
meta.configuredSecrets, meta.configuredSecrets,
@@ -266,9 +321,17 @@ export default function SystemSettingsPage() {
) : undefined, ) : undefined,
), ),
)} )}
</div> </div>
), );
})); }
return {
key: group.key,
label: group.label,
forceRender: true,
children,
};
});
}, [meta, mockSmsEnabled, loading]); }, [meta, mockSmsEnabled, loading]);
async function onSave() { async function onSave() {
+29 -8
View File
@@ -20,6 +20,9 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const; const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
@@ -253,11 +256,25 @@ export default function TestWhitelistPage() {
} }
const phoneColumns: ColumnsType<PhoneRow> = [ const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 }, {
title: '手机号',
dataIndex: 'phone',
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '备注', title: '备注',
dataIndex: 'note', dataIndex: 'note',
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -334,7 +351,7 @@ export default function TestWhitelistPage() {
if (accountType === 'partner') { if (accountType === 'partner') {
return [ return [
{ title: '姓名', dataIndex: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' }, { title: '公司', dataIndex: 'companyName', render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '标记', title: '标记',
@@ -347,7 +364,7 @@ export default function TestWhitelistPage() {
} }
if (accountType === 'store') { if (accountType === 'store') {
return [ return [
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true }, { title: '门店名', dataIndex: 'name', width: 160 },
{ title: '城市', dataIndex: 'cityName', width: 90 }, { title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 }, { title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 }, { title: '状态', dataIndex: 'status', width: 90 },
@@ -386,11 +403,15 @@ export default function TestWhitelistPage() {
]; ];
} }
const { columns, settingsButton, settingsModal } = useAdminListColumns('test-whitelist', phoneColumns, {
page: phonePage,
pageSize: phonePageSize,
});
return ( return (
<div> <div>
<Typography.Title level={4} style={{ marginTop: 0 }}> {settingsModal}
<AdminListHeader title="白名单管理" settings={settingsButton} />
</Typography.Title>
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}> <Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
@@ -472,7 +493,7 @@ export default function TestWhitelistPage() {
<Table <Table
rowKey="id" rowKey="id"
loading={phonesLoading} loading={phonesLoading}
columns={phoneColumns} columns={columns}
dataSource={phones?.items ?? []} dataSource={phones?.items ?? []}
pagination={{ pagination={{
current: phonePage, current: phonePage,
@@ -4,6 +4,10 @@ import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -69,7 +73,7 @@ export default function ThirdPartyLogsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ title: 'Provider', dataIndex: 'provider', width: 120 }, { title: 'Provider', dataIndex: 'provider', width: 120 },
{ title: '场景', dataIndex: 'scene', width: 120 }, { title: '场景', dataIndex: 'scene', width: 120 },
@@ -87,15 +91,27 @@ export default function ThirdPartyLogsPage() {
{ {
title: '错误信息', title: '错误信息',
dataIndex: 'errorMessage', dataIndex: 'errorMessage',
ellipsis: true,
render: (v: string | null | undefined) => v || '—', render: (v: string | null | undefined) => v || '—',
}, },
{ {
title: '请求摘要', title: '请求摘要',
ellipsis: true,
render: (_, r) => summarizeJson(r.requestBody), render: (_, r) => summarizeJson(r.requestBody),
}, },
{ title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' }, {
title: '外部单号',
dataIndex: 'externalNo',
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/common/third-party-logs/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '操作', title: '操作',
width: 80, width: 80,
@@ -114,9 +130,15 @@ export default function ThirdPartyLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-third-party', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> {settingsModal}
<AdminListHeader title="第三方日志" settings={settingsButton} />
<Form <Form
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
@@ -143,7 +165,7 @@ export default function ThirdPartyLogsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+25 -4
View File
@@ -25,6 +25,9 @@ import {
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -115,8 +118,22 @@ export default function TicketsPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 }, {
title: '工单号',
dataIndex: 'ticketNo',
width: 180,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '类型', title: '类型',
dataIndex: 'ticketType', dataIndex: 'ticketType',
@@ -132,7 +149,7 @@ export default function TicketsPage() {
), ),
}, },
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark' },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
@@ -154,8 +171,11 @@ export default function TicketsPage() {
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? []; const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
const { columns, settingsButton, settingsModal } = useAdminListColumns('tickets', baseColumns, { page, pageSize });
return ( return (
<div> <div>
{settingsModal}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -167,6 +187,7 @@ export default function TicketsPage() {
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}> <Button type="primary" onClick={() => setCreateOpen(true)}>
</Button> </Button>
@@ -205,7 +226,7 @@ export default function TicketsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 900 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+27 -6
View File
@@ -7,6 +7,10 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -61,10 +65,10 @@ export default function UserLogsPage() {
form.setFieldsValue(filters); form.setFieldsValue(filters);
}, [form, filters]); }, [form, filters]);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '用户', width: 180, ellipsis: true, title: '用户', width: 180,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.nickname || r.userNo} primary={r.nickname || r.userNo}
@@ -78,13 +82,27 @@ export default function UserLogsPage() {
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag> <Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 160 }, {
title: '事件',
dataIndex: 'eventName',
width: 160,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/logs/users/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '关联', width: 120, title: '关联', width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
}, },
{ {
title: '摘要', ellipsis: true, title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson), render: (_, r) => summarizeExtra(r.extraJson),
}, },
{ {
@@ -98,9 +116,12 @@ export default function UserLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-users', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> {settingsModal}
<AdminListHeader title="用户日志" settings={settingsButton} />
<Segmented <Segmented
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
@@ -131,7 +152,7 @@ export default function UserLogsPage() {
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item> <Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }} <Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && ( {detail && (
+127 -20
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
@@ -19,6 +19,9 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types'; import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = { type UserOrderRow = {
@@ -30,6 +33,77 @@ type UserOrderRow = {
createdAt: string; createdAt: string;
}; };
function HqRemarkCell({
id,
hqRemark,
onSaved,
}: {
id: string;
hqRemark: string | null;
onSaved: (id: string, hqRemark: string | null) => void;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(hqRemark ?? '');
const savingRef = useRef(false);
useEffect(() => {
if (!editing) setValue(hqRemark ?? '');
}, [hqRemark, editing]);
async function commit() {
if (savingRef.current) return;
const next = value.trim() || null;
const prev = hqRemark?.trim() || null;
setEditing(false);
if (next === prev) return;
savingRef.current = true;
try {
const res = await request<{ id: string; hqRemark: string | null }>(`/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify({ hqRemark: next ?? '' }),
});
onSaved(id, res.hqRemark);
message.success('备注已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
setValue(hqRemark ?? '');
} finally {
savingRef.current = false;
}
}
if (editing) {
return (
<Input
size="small"
autoFocus
maxLength={128}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => void commit()}
onPressEnter={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Escape') void commit();
}}
onClick={(e) => e.stopPropagation()}
/>
);
}
return (
<span
title="双击修改备注,离开后保存"
onDoubleClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
style={{ cursor: 'text', display: 'inline-block', minWidth: 48 }}
>
{hqRemark || '—'}
</span>
);
}
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */ /** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
function fmtBenefit(v: number | null | undefined) { function fmtBenefit(v: number | null | undefined) {
const n = Number(v ?? 0); const n = Number(v ?? 0);
@@ -244,7 +318,7 @@ export default function UsersPage() {
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
]; ];
const columns: ColumnsType<AdminUserRow> = [ const baseColumns: ColumnsType<AdminUserRow> = [
{ {
title: '用户编号', title: '用户编号',
dataIndex: 'userNo', dataIndex: 'userNo',
@@ -256,12 +330,38 @@ export default function UsersPage() {
</Space> </Space>
), ),
}, },
{ title: '昵称', dataIndex: 'nickname', width: 100 }, {
title: '昵称',
dataIndex: 'nickname',
width: 140,
render: (v: string | null, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '备注',
dataIndex: 'hqRemark',
width: 160,
render: (v: string | null, row) => (
<HqRemarkCell
id={row.id}
hqRemark={v}
onSaved={(id, hqRemark) => {
setData((prev) =>
prev
? { ...prev, items: prev.items.map((u) => (u.id === id ? { ...u, hqRemark } : u)) }
: prev,
);
setDetail((d) => (d && d.id === id ? { ...d, hqRemark } : d));
}}
/>
),
},
{ {
title: '手机', title: '手机',
dataIndex: 'phone', dataIndex: 'phone',
width: 120, width: 120,
render: (v) => maskPhone(v), render: (v) => v || '—',
}, },
{ {
title: '验手机', title: '验手机',
@@ -289,7 +389,6 @@ export default function UsersPage() {
title: '来源 ID', title: '来源 ID',
dataIndex: 'sourceRefId', dataIndex: 'sourceRefId',
width: 100, width: 100,
ellipsis: true,
render: (v, row) => { render: (v, row) => {
if (!v) return '—'; if (!v) return '—';
if (row.sourceType === 'PROMO_CODE') { if (row.sourceType === 'PROMO_CODE') {
@@ -306,13 +405,11 @@ export default function UsersPage() {
title: '来源标签', title: '来源标签',
dataIndex: 'sourceLabel', dataIndex: 'sourceLabel',
width: 120, width: 120,
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
title: 'deviceKey', title: 'deviceKey',
dataIndex: 'deviceKey', dataIndex: 'deviceKey',
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -377,20 +474,29 @@ export default function UsersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('users', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> {settingsModal}
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <AdminListHeader
{canDeleteUsers ? ( title="用户监控"
<Button settings={settingsButton}
danger actions={
disabled={!selectedRowKeys.length} canDeleteUsers ? (
onClick={() => void openBatchDeleteModal()} <Button
> danger
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} disabled={!selectedRowKeys.length}
</Button> onClick={() => void openBatchDeleteModal()}
) : null} >
</Space> {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号"> <Form.Item name="phone" label="手机号">
<Input placeholder="模糊搜索" allowClear /> <Input placeholder="模糊搜索" allowClear />
@@ -429,7 +535,7 @@ export default function UsersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1830 }} scroll={{ x: 'max-content' }}
rowSelection={canDeleteUsers ? { rowSelection={canDeleteUsers ? {
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true, preserveSelectedRowKeys: true,
@@ -462,6 +568,7 @@ export default function UsersPage() {
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item> <Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item> <Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item> <Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item> <Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
<Descriptions.Item label="验手机时间"> <Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
+16 -11
View File
@@ -7,6 +7,10 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime, maskPhone } from '../lib/constants'; import { fmtTime, maskPhone } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
@@ -90,12 +94,11 @@ export default function WechatBindingsPage() {
setDrawerOpen(true); setDrawerOpen(true);
} }
const columns: ColumnsType<GroupRow> = [ const baseColumns: ColumnsType<GroupRow> = [
{ {
title: 'unionId', title: 'unionId',
dataIndex: 'unionId', dataIndex: 'unionId',
width: 180, width: 180,
ellipsis: true,
render: (v) => v || <Tag> unionId</Tag>, render: (v) => v || <Tag> unionId</Tag>,
}, },
{ {
@@ -119,11 +122,12 @@ export default function WechatBindingsPage() {
title: '手机号', title: '手机号',
dataIndex: 'primaryPhone', dataIndex: 'primaryPhone',
width: 140, width: 140,
render: (v) => maskPhone(v), render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
),
}, },
{ {
title: '身份摘要', title: '身份摘要',
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
@@ -160,7 +164,6 @@ export default function WechatBindingsPage() {
}, },
{ {
title: '账号', title: '账号',
ellipsis: true,
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.name || '—'} primary={r.name || '—'}
@@ -172,14 +175,12 @@ export default function WechatBindingsPage() {
title: '归属', title: '归属',
dataIndex: 'refLabel', dataIndex: 'refLabel',
width: 160, width: 160,
ellipsis: true,
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'), render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
}, },
{ {
title: 'wxOpenId', title: 'wxOpenId',
dataIndex: 'wxOpenId', dataIndex: 'wxOpenId',
width: 160, width: 160,
ellipsis: true,
}, },
{ {
title: '手机验证', title: '手机验证',
@@ -205,12 +206,16 @@ export default function WechatBindingsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> {settingsModal}
<Typography.Paragraph type="secondary"> <AdminListHeader
unionId C HQ unionId title="微信绑定总览"
</Typography.Paragraph> settings={settingsButton}
description="按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。"
/>
<Form <Form
form={form} form={form}
+26 -11
View File
@@ -5,6 +5,10 @@ import { useAdminList } from '../lib/useAdminList';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import type { WecomBotLogDto } from '@dukang/shared-types'; import type { WecomBotLogDto } from '@dukang/shared-types';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
export default function WecomBotLogsPage() { export default function WecomBotLogsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -24,12 +28,19 @@ export default function WecomBotLogsPage() {
[filters], [filters],
); );
const columns: ColumnsType<WecomBotLogDto> = [ const baseColumns: ColumnsType<WecomBotLogDto> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { 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: '机器人',
{ title: '动作', dataIndex: 'action', width: 160, ellipsis: true }, dataIndex: 'botName',
{ title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' }, width: 120,
render: (v, r) => (
<AdminPrimaryLink onClick={() => setDetail(r)}>{v || r.botKey || ''}</AdminPrimaryLink>
),
},
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
{ title: '动作', dataIndex: 'action', width: 160 },
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
{ {
title: '结果', title: '结果',
dataIndex: 'success', dataIndex: 'success',
@@ -37,7 +48,7 @@ export default function WecomBotLogsPage() {
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>, render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>,
}, },
{ title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' }, { title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' },
{ title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true }, { title: '输入摘要', dataIndex: 'inputSummary' },
{ {
title: '操作', title: '操作',
width: 80, width: 80,
@@ -49,12 +60,16 @@ export default function WecomBotLogsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-wecom-bots', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> {settingsModal}
<Typography.Paragraph type="secondary"> <AdminListHeader
title="企微机器人日志"
</Typography.Paragraph> settings={settingsButton}
description="记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。"
/>
<Form <Form
form={form} form={form}
@@ -96,7 +111,7 @@ export default function WecomBotLogsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
+21 -5
View File
@@ -36,6 +36,9 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
name: string; name: string;
@@ -280,7 +283,7 @@ export default function WecomBotsPage() {
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b])); const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
const columns: ColumnsType<WecomBotDto> = [ const baseColumns: ColumnsType<WecomBotDto> = [
{ {
title: '头像', title: '头像',
dataIndex: 'avatarUrl', dataIndex: 'avatarUrl',
@@ -291,18 +294,24 @@ export default function WecomBotsPage() {
</Avatar> </Avatar>
), ),
}, },
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true }, {
title: '名称',
dataIndex: 'name',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
width: 120, width: 120,
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r, render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
}, },
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true }, { title: 'BotID', dataIndex: 'botId', width: 160 },
{ {
title: '权限', title: '权限',
dataIndex: 'permissions', dataIndex: 'permissions',
ellipsis: true,
render: (perms: WecomBotPermission[]) => render: (perms: WecomBotPermission[]) =>
perms.map((p) => ( perms.map((p) => (
<Tag key={p} style={{ marginBottom: 2 }}> <Tag key={p} style={{ marginBottom: 2 }}>
@@ -367,13 +376,20 @@ export default function WecomBotsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-bots', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <div>
{settingsModal}
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap> <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
{settingsButton}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
BotID / Secret / / / BotID / Secret / / /
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''} {runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
@@ -428,7 +444,7 @@ export default function WecomBotsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Avatar, Avatar,
Button, Button,
@@ -13,6 +13,7 @@ import {
Space, Space,
Switch, Switch,
Table, Table,
Tabs,
Tag, Tag,
Typography, Typography,
message, message,
@@ -21,13 +22,22 @@ import type { ColumnsType } from 'antd/es/table';
import { import {
WECOM_PUSH_CONDITION_GROUPS, WECOM_PUSH_CONDITION_GROUPS,
WECOM_PUSH_CONDITION_LABELS, WECOM_PUSH_CONDITION_LABELS,
WECOM_TEMPLATE_EVENT_KEYS,
WECOM_TEMPLATE_EVENT_LABELS,
WECOM_TEMPLATE_PLACEHOLDERS,
type WecomMessagePushDto, type WecomMessagePushDto,
type WecomPushCondition, type WecomPushCondition,
type WecomPushTemplateDto,
type WecomTemplateEventKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = { type FormValues = {
name: string; name: string;
@@ -78,7 +88,7 @@ function WecomPushConditionPicker({
); );
} }
export default function WecomMessagePushesPage() { function PushRoutesTab() {
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>(); const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
@@ -187,21 +197,20 @@ export default function WecomMessagePushesPage() {
} }
} }
const columns: ColumnsType<WecomMessagePushDto> = [ const baseColumns: ColumnsType<WecomMessagePushDto> = [
{ {
title: '名称', title: '名称',
dataIndex: 'name', dataIndex: 'name',
render: (name: string, row) => ( render: (name: string, row) => (
<Space> <Space>
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar> <Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
<span>{name}</span> <AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</Space> </Space>
), ),
}, },
{ {
title: 'Webhook', title: 'Webhook',
dataIndex: 'webhookUrlMasked', dataIndex: 'webhookUrlMasked',
ellipsis: true,
}, },
{ {
title: '推送条件', title: '推送条件',
@@ -256,13 +265,23 @@ export default function WecomMessagePushesPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-pushes', baseColumns, {
page,
pageSize,
});
return ( return (
<div> <>
<Typography.Title level={4}> · </Typography.Title> {settingsModal}
<Typography.Paragraph type="secondary"> <AdminListHeader
Webhook settings={settingsButton}
.env Webhook URL description="配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。"
</Typography.Paragraph> actions={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
<Form <Form
form={filterForm} form={filterForm}
@@ -297,9 +316,6 @@ export default function WecomMessagePushesPage() {
> >
</Button> </Button>
<Button type="primary" onClick={openCreate}>
</Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
@@ -332,10 +348,10 @@ export default function WecomMessagePushesPage() {
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}> <Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:运营告警" /> <Input placeholder="如:业务待办通知群" />
</Form.Item> </Form.Item>
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)"> <Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
<OssUpload /> <OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="webhookUrl" name="webhookUrl"
@@ -400,6 +416,208 @@ export default function WecomMessagePushesPage() {
</Descriptions> </Descriptions>
) : null} ) : null}
</Modal> </Modal>
</>
);
}
function TemplatesTab() {
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
const [loading, setLoading] = useState(false);
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [preview, setPreview] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
setTemplates(list);
setEventKey((prev) => {
const current = list.find((t) => t.eventKey === prev) ?? list[0];
if (current) {
form.setFieldsValue({
title: current.title,
body: current.body,
handleLabel: current.handleLabel,
});
return current.eventKey;
}
return prev;
});
} catch (e) {
message.error(e instanceof Error ? e.message : '加载模板失败');
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
void load();
}, [load]);
function selectEvent(key: WecomTemplateEventKey) {
setEventKey(key);
const row = templates.find((t) => t.eventKey === key);
if (row) {
form.setFieldsValue({
title: row.title,
body: row.body,
handleLabel: row.handleLabel,
});
setPreview('');
}
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}`,
{
method: 'PUT',
body: JSON.stringify(values),
},
);
message.success('模板已保存');
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function reset() {
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}/reset`,
{ method: 'POST', body: '{}' },
);
message.success('已恢复默认文案');
form.setFieldsValue({
title: updated.title,
body: updated.body,
handleLabel: updated.handleLabel,
});
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
setPreview('');
} catch (e) {
message.error(e instanceof Error ? e.message : '恢复失败');
} finally {
setSaving(false);
}
}
async function testSend() {
setTesting(true);
try {
const res = await request<{ ok: boolean; message: string; preview: string }>(
`/admin/wecom-push-templates/${eventKey}/test`,
{ method: 'POST', body: '{}' },
);
setPreview(res.preview || '');
message.success(res.message || '已发送');
} catch (e) {
message.error(e instanceof Error ? e.message : '测试失败');
} finally {
setTesting(false);
}
}
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
return (
<>
<Typography.Paragraph type="secondary">
使 {'{{orderNo}}'} {'{{handleUrl}}'}
Webhook
</Typography.Paragraph>
<Space align="start" style={{ width: '100%' }} size={24} wrap>
<div style={{ minWidth: 200 }}>
<Typography.Text strong></Typography.Text>
<div style={{ marginTop: 8 }}>
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
<div key={k} style={{ marginBottom: 4 }}>
<Button
type={k === eventKey ? 'primary' : 'text'}
size="small"
onClick={() => selectEvent(k)}
block
style={{ textAlign: 'left' }}
>
{WECOM_TEMPLATE_EVENT_LABELS[k]}
</Button>
</div>
))}
</div>
</div>
<div style={{ flex: 1, minWidth: 360 }}>
<Form form={form} layout="vertical" disabled={loading}>
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="body"
label="正文(企微 markdown"
rules={[{ required: true }]}
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
>
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
<Input placeholder="去处理" />
</Form.Item>
<Space wrap>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
<Button loading={saving}></Button>
</Popconfirm>
<Button loading={testing} onClick={() => void testSend()}>
</Button>
</Space>
</Form>
{preview ? (
<div style={{ marginTop: 16 }}>
<Typography.Text strong></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
whiteSpace: 'pre-wrap',
borderRadius: 8,
}}
>
{preview}
</pre>
</div>
) : null}
</div>
</Space>
</>
);
}
export default function WecomMessagePushesPage() {
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Tabs
items={[
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
]}
/>
</div> </div>
); );
} }
+79 -30
View File
@@ -26,6 +26,10 @@ import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { request, type HqProfile } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -37,6 +41,13 @@ type Row = {
wineryAmount: number; wineryAmount: number;
status: string; status: string;
paidAt?: string | null; paidAt?: string | null;
paymentRef?: string | null;
wineryBank?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
}; };
type BillItem = { type BillItem = {
@@ -127,14 +138,30 @@ export default function WineryBillsPage() {
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank'); (profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
function confirmPay(ids: string[], amountHint?: number) { function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({ Modal.confirm({
title: '确认打款?', title: '确认打款?',
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`, content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款', okText: '确认打款',
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) { if (ids.length === 1) {
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' }); await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else { } else {
await request('/admin/winery-bills/batch-confirm', { await request('/admin/winery-bills/batch-confirm', {
method: 'POST', method: 'POST',
@@ -212,8 +239,15 @@ export default function WineryBillsPage() {
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0); const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true }, {
title: '账单号',
dataIndex: 'billNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{ {
title: '账单日', title: '账单日',
dataIndex: 'billDate', dataIndex: 'billDate',
@@ -267,32 +301,23 @@ export default function WineryBillsPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<div {settingsModal}
style={{ <AdminListHeader
display: 'flex', title="酒厂对账单"
justifyContent: 'space-between', settings={settingsButton}
alignItems: 'flex-start', description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
marginBottom: 16, actions={
gap: 16, canEditWineryBank ? (
}} <Button type="default" onClick={() => void openBankModal()}>
>
<Space direction="vertical" size={0}> </Button>
<Typography.Title level={4} style={{ margin: 0 }}> ) : null
}
</Typography.Title> />
<Typography.Text type="secondary">
T+3 8:00 3 / × {ratePct}%绿 0
</Typography.Text>
</Space>
{canEditWineryBank ? (
<Button type="default" onClick={() => void openBankModal()}>
</Button>
) : null}
</div>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
@@ -375,7 +400,7 @@ export default function WineryBillsPage() {
onChange: setSelectedKeys, onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }), getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
}} }}
scroll={{ x: 1100 }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -399,7 +424,31 @@ export default function WineryBillsPage() {
{displayWineryStatus(detail.status, detail.wineryAmount).label} {displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item> <Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions> </Descriptions>
{detail.wineryBank ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{detail.wineryBank.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户银行">
{detail.wineryBank.bankName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户支行">
{detail.wineryBank.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{detail.wineryBank.bankAccountNo || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}> <Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title> </Typography.Title>
@@ -409,7 +458,7 @@ export default function WineryBillsPage() {
pagination={false} pagination={false}
dataSource={detail.items ?? []} dataSource={detail.items ?? []}
columns={[ columns={[
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true }, { title: '订单号', dataIndex: 'orderNo' },
{ {
title: '配送', title: '配送',
dataIndex: 'deliveryType', dataIndex: 'deliveryType',
@@ -9,6 +9,10 @@ import {
import { fmtTime } from '../../lib/constants'; import { fmtTime } from '../../lib/constants';
import { useAdminList } from '../../lib/useAdminList'; import { useAdminList } from '../../lib/useAdminList';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import { useAdminListColumns } from '../../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
import { AdminListHeader } from '../../components/AdminListHeader';
export default function PromoCodeUsersPage() { export default function PromoCodeUsersPage() {
const { id = '' } = useParams(); const { id = '' } = useParams();
@@ -21,9 +25,18 @@ export default function PromoCodeUsersPage() {
[id], [id],
); );
const columns: ColumnsType<PromoCodeAttributedUser> = [ const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, {
title: '昵称',
dataIndex: 'nickname',
width: 100,
render: (v, row) => (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
{v}
</AdminPrimaryLink>
),
},
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
{ {
title: '验手机', title: '验手机',
@@ -69,21 +82,27 @@ export default function PromoCodeUsersPage() {
}, },
]; ];
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
return ( return (
<Table <>
rowKey="id" {settingsModal}
className="admin-table-nowrap" <AdminListHeader settings={settingsButton} />
loading={loading} <Table
columns={columns} rowKey="id"
dataSource={data?.items ?? []} className="admin-table-nowrap"
scroll={{ x: 1100 }} loading={loading}
pagination={{ columns={columns}
current: page, dataSource={data?.items ?? []}
pageSize, scroll={{ x: 'max-content' }}
total: data?.total ?? 0, pagination={{
showSizeChanger: true, current: page,
onChange: (p, ps) => { setPage(p); setPageSize(ps); }, pageSize,
}} total: data?.total ?? 0,
/> showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
</>
); );
} }
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.mp3' {
const src: string;
export default src;
}
@@ -17,6 +17,11 @@ type Props = {
accept?: string; accept?: string;
/** 计量单位文案,如「张」「个」 */ /** 计量单位文案,如「张」「个」 */
unit?: string; unit?: string;
/**
* stack +
* grid+
*/
variant?: 'stack' | 'grid';
}; };
function isCancelError(msg: string): boolean { function isCancelError(msg: string): boolean {
@@ -42,6 +47,7 @@ export default function MultiOssUploadField({
label, label,
accept = 'image/*', accept = 'image/*',
unit = '张', unit = '张',
variant = 'stack',
}: Props) { }: Props) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const pickingRef = useRef(false); const pickingRef = useRef(false);
@@ -53,6 +59,7 @@ export default function MultiOssUploadField({
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
const remaining = Math.max(0, maxCount - urls.length); const remaining = Math.max(0, maxCount - urls.length);
const inWechat = isWechatEnv(); const inWechat = isWechatEnv();
const isGrid = variant === 'grid';
useEffect(() => { useEffect(() => {
urlsRef.current = urls; urlsRef.current = urls;
@@ -126,11 +133,78 @@ export default function MultiOssUploadField({
} }
} }
function openPicker() {
if (inWechat) void pickWechat();
else inputRef.current?.click();
}
function removeAt(index: number) { function removeAt(index: number) {
if (disabled) return; if (disabled) return;
onChange?.(urls.filter((_, i) => i !== index)); onChange?.(urls.filter((_, i) => i !== index));
} }
const thumb = (url: string, index: number) => (
<div key={`${url}-${index}`} className={isGrid ? 'partner-upload-grid-thumb' : undefined} style={isGrid ? undefined : { position: 'relative', width: 88, height: 88 }}>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: isGrid ? '100%' : 88,
height: isGrid ? '100%' : 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: isGrid ? '100%' : 88,
height: isGrid ? '100%' : 88,
objectFit: 'cover',
borderRadius: isGrid ? 12 : 8,
display: 'block',
}}
/>
)}
{!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>
);
return ( return (
<div className="partner-oss-upload"> <div className="partner-oss-upload">
<input <input
@@ -146,86 +220,49 @@ export default function MultiOssUploadField({
}} }}
/> />
{urls.length > 0 ? ( {isGrid ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}> <div className="partner-upload-grid">
{urls.map((url, index) => ( {urls.map((url, index) => thumb(url, index))}
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}> {remaining > 0 && !disabled ? (
{isPdf(url) ? ( <button
<a type="button"
href={url} className="partner-upload-dashed partner-upload-dashed--compact"
target="_blank" disabled={uploading}
rel="noreferrer" onClick={openPicker}
style={{ aria-label={uploading ? '上传中' : `添加${unit}${urls.length}/${maxCount}`}
display: 'flex', >
width: 88, <span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
height: 88, {uploading ? 'hourglass_top' : 'add'}
flexDirection: 'column', </span>
alignItems: 'center', </button>
justifyContent: 'center', ) : null}
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> </div>
) : null} ) : (
<>
<button {urls.length > 0 ? (
type="button" <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
className="partner-upload-dashed partner-upload-dashed--compact" {urls.map((url, index) => thumb(url, index))}
disabled={disabled || uploading || remaining <= 0} </div>
onClick={() => { ) : null}
if (inWechat) void pickWechat(); <button
else inputRef.current?.click(); type="button"
}} className="partner-upload-dashed partner-upload-dashed--compact"
> disabled={disabled || uploading || remaining <= 0}
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}> onClick={openPicker}
{uploading ? 'hourglass_top' : 'add_a_photo'} >
</span> <span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
<span className="text-primary" style={{ fontWeight: 500 }}> {uploading ? 'hourglass_top' : 'add_a_photo'}
{uploading </span>
? '上传中…' <span className="text-primary" style={{ fontWeight: 500 }}>
: remaining <= 0 {uploading
? `已达上限 ${maxCount}${unit}` ? '上传中…'
: label ?? `批量上传(${urls.length}/${maxCount}`} : remaining <= 0
</span> ? `已达上限 ${maxCount}${unit}`
</button> : label ?? `批量上传(${urls.length}/${maxCount}`}
</span>
</button>
</>
)}
{error ? ( {error ? (
<p className="partner-form-error" role="alert"> <p className="partner-form-error" role="alert">
{error} {error}
@@ -20,6 +20,7 @@ import {
} from '../lib/api'; } from '../lib/api';
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth'; import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin'; import { isWechatEnv } from '../lib/weixin';
import { toastError } from '../lib/toast';
export type PartnerAccount = PartnerMe & { export type PartnerAccount = PartnerMe & {
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
@@ -115,7 +116,10 @@ export function PartnerSessionProvider({ children }: { children: ReactNode }) {
} }
stripOAuthParamsFromLocation(); stripOAuthParamsFromLocation();
} }
} catch { } catch (e) {
// 微信 OAuth 回跳后后端可能因账号暂停(DISABLED)等拒绝登录,
// 必须把错误显式提示出来,否则用户无任何反馈(与短信路径一致)。
toastError(e instanceof Error ? e.message : '微信登录失败');
stripOAuthParamsFromLocation(); stripOAuthParamsFromLocation();
} }
} }
+45 -4
View File
@@ -156,10 +156,19 @@ async function rawRequest<T>(
if (authToken) headers.Authorization = `Bearer ${authToken}`; if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers }); const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })); const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })) as {
code: number;
message?: string;
data?: T;
reason?: string;
};
if (json.code !== 0) { if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number }; const err = new Error(json.message || '请求失败') as Error & {
status?: number;
reason?: string;
};
err.status = json.code === 401 ? 401 : json.code; err.status = json.code === 401 ? 401 : json.code;
err.reason = json.reason;
if (json.code === 400) { if (json.code === 400) {
reportApiError( reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) }, { apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
@@ -224,8 +233,19 @@ export async function request<T>(
try { try {
return await requestWithAuthRetry<T>(path, fetchOptions); return await requestWithAuthRetry<T>(path, fetchOptions);
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; const err = e as Error & { status?: number; reason?: string };
const message = err.message || '请求失败'; const message = err.message || '请求失败';
// 账号停用 / 合伙人绑定失效:强制退出登录
if (err.reason === 'ACCOUNT_DISABLED') {
if (localStorage.getItem(ACCESS_TOKEN)) {
clearAuth({ keepProfile: true });
if (!silent) showPartnerToast(message, 'error');
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
window.location.href = toAppPath('/login?disabled=1');
}
}
throw e;
}
if (err.status === 401) { if (err.status === 401) {
if (localStorage.getItem(ACCESS_TOKEN)) { if (localStorage.getItem(ACCESS_TOKEN)) {
clearAuth({ keepProfile: true }); clearAuth({ keepProfile: true });
@@ -260,7 +280,14 @@ export async function ensureSession(): Promise<{ authenticated: boolean; partner
touchPartnerSession(); touchPartnerSession();
return { authenticated: true, partner }; return { authenticated: true, partner };
} catch (e) { } catch (e) {
const err = e as Error & { status?: number }; const err = e as Error & { status?: number; reason?: string };
if (err.reason === 'ACCOUNT_DISABLED') {
clearAuth({ keepProfile: true });
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
window.location.href = toAppPath('/login?disabled=1');
}
return { authenticated: false, partner: getPartnerProfile() };
}
if (err.status === 401) { if (err.status === 401) {
const refreshed = await refreshSession(); const refreshed = await refreshSession();
if (refreshed?.partner) { if (refreshed?.partner) {
@@ -277,3 +304,17 @@ export async function ensureSession(): Promise<{ authenticated: boolean; partner
/** @deprecated 使用 PartnerSessionPayload */ /** @deprecated 使用 PartnerSessionPayload */
export type PartnerAuthPayload = PartnerSessionPayload; export type PartnerAuthPayload = PartnerSessionPayload;
export function submitStoreInfoChangeRequest(storeId: string, fields: Record<string, unknown>) {
return request('PARTNER_H5', `/partner/stores/${storeId}/info-change-request`, {
method: 'POST',
body: JSON.stringify(fields),
});
}
export function listStoreInfoChangeRequests(storeId: string) {
return request<Array<{ status?: string }>>(
'PARTNER_H5',
`/partner/stores/${storeId}/info-change-requests`,
);
}
+25 -5
View File
@@ -71,16 +71,24 @@ function formatPartnerError(e: unknown): string {
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) { if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
return '未找到合伙人账号'; return '未找到合伙人账号';
} }
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) { if (
return '合伙人账号已暂停,无法登录'; text.includes('合伙人账号已停用') ||
text.includes('账号已停用') ||
text.includes('暂停使用')
) {
return '该账号已暂停使用,请联系客服人员';
} }
return text; return text;
} }
function formatWechatError(e: unknown): string { function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败'; const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) { if (
return '合伙人账号已暂停,无法登录'; text.includes('合伙人账号已停用') ||
text.includes('账号已停用') ||
text.includes('暂停使用')
) {
return '该账号已暂停使用,请联系客服人员';
} }
if (text.includes('首次登录') || text.includes('手机验证码')) { if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
@@ -91,7 +99,7 @@ function formatWechatError(e: unknown): string {
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession, refresh, account } = usePartnerSession(); const { applySession, refresh, account } = usePartnerSession();
const [params] = useSearchParams(); const [params, setSearchParams] = useSearchParams();
const quick = params.get('quick') === '1'; const quick = params.get('quick') === '1';
const savedProfile = getPartnerProfile(); const savedProfile = getPartnerProfile();
const remembered = loadRememberedPhone(); const remembered = loadRememberedPhone();
@@ -112,6 +120,18 @@ export default function LoginPage() {
.catch(() => setWxAuthorize(false)); .catch(() => setWxAuthorize(false));
}, []); }, []);
useEffect(() => {
if (params.get('disabled') === '1') {
const tip = '账号已停用或解绑,请重新登录';
setMsg(tip);
toastError(tip);
const next = new URLSearchParams(params);
next.delete('disabled');
setSearchParams(next, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const quickName = savedProfile?.name ?? '城市合伙人'; const quickName = savedProfile?.name ?? '城市合伙人';
const quickCompany = savedProfile?.companyName ?? ''; const quickCompany = savedProfile?.companyName ?? '';
const quickPhone = savedProfile?.phone || phone; const quickPhone = savedProfile?.phone || phone;
+63 -5
View File
@@ -58,6 +58,7 @@ export default function ProxyOrderPage() {
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes); const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail); const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
const [productId, setProductId] = useState(draft0.productId); const [productId, setProductId] = useState(draft0.productId);
const [skuId, setSkuId] = useState('');
const [quantity, setQuantity] = useState(draft0.quantity); const [quantity, setQuantity] = useState(draft0.quantity);
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId); const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode); const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
@@ -156,9 +157,37 @@ export default function ProxyOrderPage() {
]); ]);
const selectedProduct = options?.products.find((p) => p.id === productId); const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true; const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const allowOnSite = !!selectedProduct?.allowOnSitePickup; const selectedSku =
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true; skuOptions.find((s) => s.id === skuId) ||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
skuOptions[0];
const allowOnline = selectedSku
? selectedSku.allowOnlinePurchase !== false
: selectedProduct
? selectedProduct.allowOnlinePurchase !== false
: true;
const allowOnSite = selectedSku
? !!selectedSku.allowOnSitePickup
: !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedSku
? selectedSku.allowCrossCityDelivery !== false
: selectedProduct
? selectedProduct.allowCrossCityDelivery !== false
: true;
useEffect(() => {
if (!selectedProduct) return;
const def =
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
skuOptions.find((s) => s.isDefault) ||
skuOptions[0];
if (def && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
setSkuId(def.id);
if (def.saleUnit === 'BOX') setQuantity(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, selectedProduct?.id, options]);
useEffect(() => { useEffect(() => {
if (!selectedProduct) return; if (!selectedProduct) return;
@@ -184,6 +213,7 @@ export default function ProxyOrderPage() {
productId, productId,
quantity, quantity,
deliveryMode, deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined, receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined, receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}), }),
@@ -200,7 +230,7 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false)); .finally(() => setPreviewLoading(false));
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [productId, quantity, deliveryMode, region?.city, region?.district]); }, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
function validateForm(): string | null { function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号'; if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
@@ -359,6 +389,7 @@ export default function ProxyOrderPage() {
productId, productId,
quantity, quantity,
promoCodeId: promoCodeId || undefined, promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
}; };
setSubmitting(true); setSubmitting(true);
@@ -524,8 +555,35 @@ export default function ProxyOrderPage() {
</button> </button>
</section> </section>
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<select
className="partner-input"
value={skuId}
onChange={(e) => {
const id = e.target.value;
setSkuId(id);
const sku = skuOptions.find((s) => s.id === id);
if (sku?.saleUnit === 'BOX') setQuantity(1);
}}
>
{skuOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.specText || '默认'} · ¥{fmtMoney(s.price)} ·{' '}
{s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}
</option>
))}
</select>
</div>
</section>
) : null}
<section className="partner-form-section"> <section className="partner-form-section">
<label className="partner-form-label"></label> <label className="partner-form-label">
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
</label>
<div className="partner-input-wrap"> <div className="partner-input-wrap">
<input <input
className="partner-input" className="partner-input"
@@ -1007,9 +1007,9 @@ export default function StoreCreatePage() {
<MultiOssUploadField <MultiOssUploadField
bizType="STORE_ENV" bizType="STORE_ENV"
maxCount={20} maxCount={20}
variant="grid"
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)} value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))} onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20`}
/> />
</section> </section>
+147 -82
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api'; import { request, submitStoreInfoChangeRequest, listStoreInfoChangeRequests } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess'; import { canManagePartnerStore } from '../lib/partnerAccess';
@@ -56,10 +56,10 @@ export default function StoreDetailPage() {
const [status, setStatus] = useState<StoreStatusValue>('OPEN'); const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false); const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false); const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState(''); const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
const [pendingInfoChange, setPendingInfoChange] = useState(false);
function applyStore(data: Record<string, unknown>) { function applyStore(data: Record<string, unknown>) {
setStore(data); setStore(data);
@@ -97,6 +97,12 @@ export default function StoreDetailPage() {
setStore(null); setStore(null);
setLoadError(e instanceof Error ? e.message : '加载失败'); setLoadError(e instanceof Error ? e.message : '加载失败');
}); });
// 拉取该门店的信息变更审核记录,若有 PENDING 则展示「审核中」横幅
listStoreInfoChangeRequests(id)
.then((reqs) =>
setPendingInfoChange(Array.isArray(reqs) && reqs.some((r) => r.status === 'PENDING')),
)
.catch(() => {});
}, [id]); }, [id]);
async function changeStatus(next: StoreStatusValue) { async function changeStatus(next: StoreStatusValue) {
@@ -152,77 +158,140 @@ export default function StoreDetailPage() {
} }
} }
async function saveBasic() { function mediaChanged(): boolean {
if (!store) return false;
const nextCover = coverUrl.trim();
const origCover = String(store.coverUrl || '').trim();
if (nextCover !== origCover) return true;
const nextEnv = uniqueEnvUrls(envPhotoUrls);
const origEnv = uniqueEnvUrls(
Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || ''))
: [],
);
if (nextEnv.length !== origEnv.length) return true;
return nextEnv.some((u, i) => u !== origEnv[i]);
}
function basicChanged(): boolean {
if (!store) return false;
const liveContact = String(store.contactPhone || store.phone || '').trim();
const liveIntro = String(store.intro || '').trim();
const liveRuleRaw = String(store.benefitUsageRule || '').trim();
const liveRule = liveRuleRaw && !/^null$/i.test(liveRuleRaw) ? liveRuleRaw : '';
const liveLat =
store.latitude != null && store.latitude !== '' ? String(store.latitude) : '';
const liveLng =
store.longitude != null && store.longitude !== '' ? String(store.longitude) : '';
return (
form.name.trim() !== String(store.name || '').trim() ||
form.contactPhone.trim() !== liveContact ||
form.address.trim() !== String(store.address || '').trim() ||
form.intro.trim() !== liveIntro ||
form.benefitUsageRule.trim() !== liveRule ||
form.latitude.trim() !== liveLat ||
form.longitude.trim() !== liveLng
);
}
async function saveChanges() {
if (!id || saving || status === 'CLOSED') return; if (!id || saving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase(); const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') { if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料'); setActionError('门店审核中,暂不可修改资料');
return; return;
} }
const wantMedia = mediaChanged();
const wantBasic = basicChanged();
if (!wantMedia && !wantBasic) {
setActionError('没有检测到需要变更的字段');
return;
}
const nextCover = coverUrl.trim();
const nextEnv = uniqueEnvUrls(envPhotoUrls);
if (wantMedia) {
if (!nextCover) {
setActionError('请上传门头照');
return;
}
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
return;
}
}
setSaving(true); setSaving(true);
setActionError(''); setActionError('');
try { try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, { // 入驻被驳回:直写 media/basic 并重提门店审核;已通过门店:统一走信息变更审核
method: 'PUT', if (auditStatus === 'REJECTED') {
body: JSON.stringify({ if (wantMedia) {
name: form.name.trim(), const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
contactPhone: form.contactPhone.trim(), method: 'PUT',
address: form.address.trim(), body: JSON.stringify({
intro: form.intro.trim(), coverUrl: nextCover,
benefitUsageRule: form.benefitUsageRule.trim() || null, envPhotoUrls: nextEnv,
...(form.latitude.trim() && form.longitude.trim() }),
? { });
latitude: Number(form.latitude), applyStore(data);
longitude: Number(form.longitude), }
} if (wantBasic) {
: {}), const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
}), method: 'PUT',
}); body: JSON.stringify({
applyStore(data); name: form.name.trim(),
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存'); contactPhone: form.contactPhone.trim(),
address: form.address.trim(),
intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
}),
});
applyStore(data);
}
toastSuccess('已重新提交审核');
return;
}
const fields: Record<string, unknown> = {};
if (wantBasic) {
fields.name = form.name.trim();
fields.contactPhone = form.contactPhone.trim();
fields.address = form.address.trim();
fields.intro = form.intro.trim();
fields.benefitUsageRule = form.benefitUsageRule.trim() || null;
if (form.latitude.trim() && form.longitude.trim()) {
fields.latitude = Number(form.latitude);
fields.longitude = Number(form.longitude);
}
}
if (wantMedia) {
fields.coverUrl = nextCover;
fields.envPhotoUrls = nextEnv;
}
await submitStoreInfoChangeRequest(id, fields);
setPendingInfoChange(true);
toastSuccess(
wantMedia
? '变更已提交(含门头照/环境图),总部审核通过后生效'
: '变更已提交,等待总部审核',
);
} catch (e) { } catch (e) {
setActionError(e instanceof Error ? e.message : '保存失败'); setActionError(e instanceof Error ? e.message : '提交失败');
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
async function saveMedia() {
if (!id || mediaSaving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料');
return;
}
const nextCover = coverUrl.trim();
const nextEnv = uniqueEnvUrls(envPhotoUrls);
if (!nextCover) {
setActionError('请上传门头照');
return;
}
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
return;
}
setMediaSaving(true);
setActionError('');
try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
method: 'PUT',
body: JSON.stringify({
coverUrl: nextCover,
envPhotoUrls: nextEnv,
}),
});
applyStore(data);
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
} catch (e) {
setActionError(e instanceof Error ? e.message : '照片更新失败');
} finally {
setMediaSaving(false);
}
}
if (loadError) { if (loadError) {
return ( return (
<div className="partner-detail-page partner-home--flush-top"> <div className="partner-detail-page partner-home--flush-top">
@@ -279,6 +348,16 @@ export default function StoreDetailPage() {
{auditStatus === 'APPROVED' && ( {auditStatus === 'APPROVED' && (
<p className="label-md text-muted"></p> <p className="label-md text-muted"></p>
)} )}
{pendingInfoChange && (
<div style={{ marginTop: 12, background: 'rgba(245,166,35,0.08)', border: '1px solid rgba(245,166,35,0.3)', borderRadius: 8, padding: 12 }}>
<p className="body-md" style={{ fontWeight: 600, marginBottom: 4 }}>
</p>
<p className="label-md text-muted" style={{ margin: 0 }}>
线
</p>
</div>
)}
</section> </section>
{canMutate && !auditPending && status !== 'CLOSED' && ( {canMutate && !auditPending && status !== 'CLOSED' && (
@@ -430,27 +509,13 @@ export default function StoreDetailPage() {
</span> </span>
</div> </div>
{canMutate && !readOnly ? ( {canMutate && !readOnly ? (
<> <MultiOssUploadField
<MultiOssUploadField bizType="STORE_ENV"
bizType="STORE_ENV" maxCount={20}
maxCount={20} variant="grid"
value={uniqueEnvUrls(envPhotoUrls)} value={uniqueEnvUrls(envPhotoUrls)}
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])} onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20`} />
/>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 16 }}
disabled={mediaSaving}
onClick={() => void saveMedia()}
>
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
upload
</span>
{mediaSaving ? '上传中…' : '重新上传照片'}
</button>
</>
) : envPhotos.length > 0 ? ( ) : envPhotos.length > 0 ? (
<div className="partner-photo-grid"> <div className="partner-photo-grid">
{envPhotos.map((url, index) => ( {envPhotos.map((url, index) => (
@@ -480,9 +545,9 @@ export default function StoreDetailPage() {
<footer className="partner-save-footer"> <footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button> <button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{canMutate && ( {canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}> <button type="button" className="partner-save-submit" onClick={() => void saveChanges()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span> <span className="material-symbols-outlined">send</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'} {saving ? '提交中…' : '提交变更'}
</button> </button>
)} )}
</footer> </footer>
+9
View File
@@ -1622,10 +1622,19 @@ nav.app-tabbar .app-tabbar-label {
gap: 8px; gap: 8px;
} }
.partner-upload-grid-thumb {
position: relative;
aspect-ratio: 1;
width: 100%;
border-radius: 12px;
overflow: hidden;
}
.partner-upload-grid .partner-upload-dashed { .partner-upload-grid .partner-upload-dashed {
aspect-ratio: 1; aspect-ratio: 1;
padding: 12px; padding: 12px;
font-size: 10px; font-size: 10px;
width: 100%;
} }
.partner-upload-dashed--compact { .partner-upload-dashed--compact {
+5 -1
View File
@@ -1,6 +1,7 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api'; import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { peekShopReturnTo, rememberShopReturnPath } from '../lib/shop-redeem-return';
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
const SELECT_STORE_PATH = '/select-store'; const SELECT_STORE_PATH = '/select-store';
@@ -18,14 +19,17 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (authenticated && location.pathname === '/login') { if (authenticated && location.pathname === '/login') {
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />; const next = needsSelectStore ? SELECT_STORE_PATH : (peekShopReturnTo() || '/');
return <Navigate to={next} replace />;
} }
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) { if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
rememberShopReturnPath(location);
return <Navigate to={SELECT_STORE_PATH} replace />; return <Navigate to={SELECT_STORE_PATH} replace />;
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
rememberShopReturnPath(location);
const profile = getStoreProfile(); const profile = getStoreProfile();
if (profile && hasShopWxSession() && location.pathname !== '/login') { if (profile && hasShopWxSession() && location.pathname !== '/login') {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />; return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
@@ -1,6 +1,7 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { uploadRedeemPendingPhoto } from '../lib/upload'; import { uploadRedeemPendingPhoto } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin'; import { isWechatEnv, weixinSdk } from '../lib/weixin';
import type { RedeemPendingSubmitResult } from '@dukang/shared-types'; import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
@@ -18,25 +19,22 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
const [previewUrl, setPreviewUrl] = useState(''); const [previewUrl, setPreviewUrl] = useState('');
const [photoResourceId, setPhotoResourceId] = useState(''); const [photoResourceId, setPhotoResourceId] = useState('');
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null); const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
const [msg, setMsg] = useState('');
const [showAlbumFallback, setShowAlbumFallback] = useState(false); const [showAlbumFallback, setShowAlbumFallback] = useState(false);
async function handleFile(file: File) { async function handleFile(file: File) {
setUploading(true); setUploading(true);
setMsg('');
try { try {
const registered = await uploadRedeemPendingPhoto(file); const registered = await uploadRedeemPendingPhoto(file);
setPhotoResourceId(registered.id); setPhotoResourceId(registered.id);
setPreviewUrl(registered.url); setPreviewUrl(registered.url);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '上传失败'); toastError(e instanceof Error ? e.message : '上传失败');
} finally { } finally {
setUploading(false); setUploading(false);
} }
} }
async function pickPhoto() { async function pickPhoto() {
setMsg('');
if (isWechatEnv()) { if (isWechatEnv()) {
try { try {
setUploading(true); setUploading(true);
@@ -51,7 +49,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
} catch (e) { } catch (e) {
const text = e instanceof Error ? e.message : '选图失败'; const text = e instanceof Error ? e.message : '选图失败';
if (!/cancel/i.test(text)) { if (!/cancel/i.test(text)) {
setMsg(`${text},可改从系统相册选择`); toastError(`${text},可改从系统相册选择`);
setShowAlbumFallback(true); setShowAlbumFallback(true);
inputRef.current?.click(); inputRef.current?.click();
} }
@@ -65,11 +63,10 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
async function submitPending() { async function submitPending() {
if (!photoResourceId) { if (!photoResourceId) {
setMsg('请先拍摄或上传核销码照片'); toastError('请先拍摄或上传核销码照片');
return; return;
} }
setSubmitting(true); setSubmitting(true);
setMsg('');
try { try {
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', { const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
method: 'POST', method: 'POST',
@@ -81,7 +78,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
}); });
setResult(res); setResult(res);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '提交失败'); toastError(e instanceof Error ? e.message : '提交失败');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -89,8 +86,8 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
function copyText(text: string) { function copyText(text: string) {
void navigator.clipboard?.writeText(text).then( void navigator.clipboard?.writeText(text).then(
() => setMsg('已复制'), () => toastSuccess('已复制'),
() => setMsg('复制失败,请手动长按复制'), () => toastError('复制失败,请手动长按复制'),
); );
} }
@@ -169,8 +166,6 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
</div> </div>
</> </>
)} )}
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
</section> </section>
); );
} }
@@ -0,0 +1,53 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { registerShopToastListener, type ShopToastVariant } from '../lib/toast';
type ShopToastContextValue = {
showToast: (message: string, variant?: ShopToastVariant) => void;
};
const ShopToastContext = createContext<ShopToastContextValue | null>(null);
export function ShopToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState('');
const [variant, setVariant] = useState<ShopToastVariant>('error');
const timerRef = useRef<number | null>(null);
const showToast = useCallback((message: string, nextVariant: ShopToastVariant = 'error') => {
if (timerRef.current) window.clearTimeout(timerRef.current);
setVariant(nextVariant);
setToast(message);
timerRef.current = window.setTimeout(() => {
setToast('');
timerRef.current = null;
}, nextVariant === 'error' ? 2800 : 2000);
}, []);
useEffect(() => {
registerShopToastListener(showToast);
return () => {
registerShopToastListener(null);
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, [showToast]);
return (
<ShopToastContext.Provider value={{ showToast }}>
{children}
{toast ? (
<div
className={`shop-float-toast${variant === 'error' ? ' shop-float-toast--error' : ''}`}
role="alert"
aria-live="assertive"
>
{toast}
</div>
) : null}
</ShopToastContext.Provider>
);
}
export function useShopToast(): ShopToastContextValue {
const ctx = useContext(ShopToastContext);
if (!ctx) throw new Error('useShopToast 必须在 ShopToastProvider 内使用');
return ctx;
}

Some files were not shown because too many files have changed in this diff Show More