249 Commits

Author SHA1 Message Date
jacy fb66258b6a merge(dev_jacy): 门店账单确认打款支持上传凭证照片
CI / verify (push) Waiting to run
2026-08-30 15:26:32 +08:00
jacy e68eb4d38c feat(settlement): 门店账单确认打款支持上传凭证照片
财务确认打款与提现通过可附带 OSS 凭证图,详情与导出展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 15:25:56 +08:00
jacy b375fab44a v3.5.16小程序首页动画 2026-08-30 15:07:58 +08:00
jacy 58010feed8 Merge branch 'main' of git.yqidian.com:jacy/dukang into dev
CI / verify (push) Waiting to run
2026-08-30 14:40:56 +08:00
jacy 9f14c59f22 merge(dev_jacy): v4.0.1 partner assoc and H5 user management 2026-08-30 14:35:56 +08:00
jacy 9c8d5f2cad feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 14:35:39 +08:00
jacy 870494563d merge(dev): store bill issue date to today
CI / verify (push) Has been cancelled
2026-08-28 14:14:39 +08:00
jacy 4c9b3f72bc merge(dev_jacy): store bill issue date to today 2026-08-28 14:13:28 +08:00
jacy 3b669f7e38 fix(settlement): 门店 T+1 出账日改为窗口中的今天
HQ 门店账单列名改为出账日;billDate 记今日(昨日 00:00–今日 00:00 右端),启动时把旧数据从核销自然日对齐过来。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:12:35 +08:00
jacy 14c5c8c3f3 merge(dev): v3.5.18 HQ 明文手机、任务版本与核销快链
CI / verify (push) Has been cancelled
2026-08-28 13:58:57 +08:00
jacy a7f3283ae5 merge(dev_jacy): v3.5.18 HQ 明文手机、任务版本与核销快链 2026-08-28 13:58:37 +08:00
jacy da19c39965 feat(admin): v3.5.18 用户详情明文手机、任务关联版本与核销快链
HQ 后台:用户/核销记录展示完整手机号;任务列表与编辑可关联版本;账单核销单号与券号/用户可快链;门店账单日标明为出账自然日;技术支持操作按钮右对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:58:08 +08:00
jacy c06251f0a4 merge(dev): HQ 订单筛选改版与用户商品模糊搜索
CI / verify (push) Has been cancelled
2026-08-28 13:12:52 +08:00
jacy faa1830bde merge(dev_jacy): HQ 订单筛选改版与用户商品模糊搜索 2026-08-28 13:11:17 +08:00
jacy b226af2adc feat(admin): 订单页筛选改版并支持用户与商品模糊搜索
收起次要筛选项,去掉过滤测试;列表可按用户编号/昵称/备注/手机与商品名称/规格查询。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:10:36 +08:00
jacy 2b5f42acdf merge(dev): HQ 推广码订单只计已完成并补快链
CI / verify (push) Has been cancelled
2026-08-28 12:57:03 +08:00
jacy 71149044ca merge(dev_jacy): HQ 推广码订单只计已完成并补快链 2026-08-28 12:56:02 +08:00
jacy fccfd7abbe feat(promo): HQ 推广码订单只计已完成并补扫码订单快链
列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:55:19 +08:00
jacy 1ecc6a11df merge(dev): 企微结算账单明细与成交播报用户明文
CI / verify (push) Has been cancelled
2026-08-27 11:46:12 +08:00
jacy cfd42dfc32 merge(dev_jacy): 企微结算账单明细与成交播报用户明文 2026-08-27 11:45:59 +08:00
jacy 737efb89a3 feat(wecom): 结算账单明细与成交播报用户明文
支付/核销补 HQ 备注且手机号不脱敏;结算通知按城市、合伙人、笔数和累计金额分块推送。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 11:45:08 +08:00
jacy a423fb71e8 merge(dev): v3.5.14 提交订单/支付成功日志端修复
CI / verify (push) Has been cancelled
2026-08-26 17:25:25 +08:00
jacy 4111e5c0a6 merge(dev_jacy): v3.5.14 提交订单/支付成功日志端修复 2026-08-26 17:25:15 +08:00
jacy 3dbb73e428 fix(trade): v3.5.14 提交订单/支付成功日志使用真实 clientApp
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:24:56 +08:00
jacy 3a9f27eb38 merge(dev): v3.5.12 大屏BGM、中文展示、分类删除与侧栏
CI / verify (push) Has been cancelled
2026-08-26 10:55:40 +08:00
jacy e2f5aa9dd8 merge(dev_jacy): v3.5.12 大屏BGM、中文展示、分类删除与侧栏 2026-08-26 10:55:14 +08:00
jacy 58a8b47532 feat(admin): v3.5.12 大屏BGM、中文展示、分类删除修复与侧栏顺序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:54:19 +08:00
jacy 6a5c0cb090 Merge pull request 'Dev' (#53) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/53
2026-08-26 09:56:50 +08:00
jacy 448b9983d3 Merge pull request 'Dev jacy v3.5.11 企业微信通知调整' (#52) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/52
2026-08-26 09:56:36 +08:00
jacy 3afbdfe6b9 v3.5.11企业微信通知调整
CI / verify (pull_request) Has been cancelled
2026-08-26 09:55:19 +08:00
jacy 632539e8dc 增加现场提货的配置 2026-08-26 09:22:29 +08:00
jacy c7cd0f6cf2 C端SKU数量小于等于1不展示chip
后端修复规格轴数据错乱显示问题
2026-08-26 07:43:26 +08:00
jacy 00bb6a2227 Merge pull request 'Dev' (#51) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/51
2026-08-26 07:16:04 +08:00
jacy 1f06530894 Merge pull request '小程序版本号' (#50) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/50
2026-08-26 07:15:52 +08:00
jacy 1f4506f60b 小程序版本号
CI / verify (pull_request) Has been cancelled
2026-08-26 07:13:54 +08:00
jacy 93390ccf75 merge(dev): 同城运费路由与配送单信息
CI / verify (push) Has been cancelled
2026-08-25 21:13:05 +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 8f2ebbfef4 merge(dev): 补承运商删除操作常量
CI / verify (push) Has been cancelled
2026-08-25 14:32:32 +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 f2fbf95c53 merge(dev): v3.5.10 同城配送提示与企微客服
CI / verify (push) Has been cancelled
2026-08-25 14:30:43 +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 43de361e61 merge(dev): 列设置靠右与订单状态多选
CI / verify (push) Has been cancelled
2026-08-25 14:20:39 +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 0b548a2764 Merge pull request 'Dev' (#49) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/49
2026-08-25 12:39:52 +08:00
jacy 30f8d649cb Merge pull request 'v3.5.9版本迭代列表优化' (#48) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
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) Has been cancelled
2026-08-25 12:38:21 +08:00
jacy 40e8a12596 Merge pull request 'Dev' (#47) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/47
2026-08-25 10:10:20 +08:00
jacy d7241d54b1 Merge pull request '给用户增加“备注”列' (#46) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/46
2026-08-25 10:10:07 +08:00
jacy 282da24bc4 给用户增加“备注”列
CI / verify (pull_request) Has been cancelled
2026-08-25 10:09:05 +08:00
jacy 3d819dc10b Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#45) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/45
2026-08-25 09:23:12 +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) Has been cancelled
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) Has been cancelled
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 8984465893 Merge pull request 'Dev' (#43) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/43
2026-08-23 19:27:28 +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 3f208d27a9 Merge pull request 'Dev' (#41) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/41
2026-08-23 17:34:40 +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 fa69ced448 Merge pull request 'Dev v3.5.6' (#39) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/39
2026-08-23 16:43:51 +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 18c5abea79 Merge pull request 'v3.5.5版本合并' (#37) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/37
2026-08-23 12:30:04 +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 5d4ed566de merge(dev): HQ product list as SPU with expandable SKUs
CI / verify (push) Has been cancelled
2026-08-21 23:52:41 +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 5fdddae41c merge(dev): v3.5.4 规格主图与门店使用规则
CI / verify (push) Has been cancelled
2026-08-21 17:37:20 +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 22c0b03a47 Merge pull request 'Dev' (#35) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/35
2026-08-21 16:24:07 +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 f2d03e2595 Merge pull request 'v3.5.4' (#33) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/33
2026-08-21 15:50:30 +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 0c28c204b2 Merge pull request 'Dev' (#31) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/31
2026-08-20 20:23:20 +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 e7f49a9639 Merge pull request 'v3.5.3版本更新1' (#28) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/28
2026-08-20 18:55:18 +08:00
jacy fc2e5b65de v3.5.3版本更新1
CI / verify (pull_request) Has been cancelled
2026-08-20 18:54:15 +08:00
jacy f941dcf072 Merge pull request 'Dev' (#27) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/27
2026-08-20 15:06:24 +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 3382d36a6c Merge pull request 'Dev jacy 大屏优化' (#24) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/24
2026-08-20 13:43:06 +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 9b8e3f1347 Merge pull request 'v3.5.2 大屏调整' (#23) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/23
2026-08-20 12:01:21 +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 1440a59fc8 Merge pull request 'Dev' (#21) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/21
2026-08-20 00:54:54 +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 11659484c6 Merge pull request 'v3.5.1 版本更新' (#19) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/19
2026-08-19 16:01:31 +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 0fb7ab7abb Merge pull request 'v3.4.18' (#17) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/17
2026-08-17 23:45:19 +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
jacy 984dec79ee Merge pull request 'Dev v3.4.17 版本发布' (#15) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/15
2026-08-16 12:31:16 +08:00
jacy f84614bbb4 Merge pull request 'v3.4.17版本发布' (#14) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/14
2026-08-16 12:30:21 +08:00
jacy c0ce494732 提交workbuddy记忆缓存
CI / verify (pull_request) Has been cancelled
2026-08-16 12:29:07 +08:00
jacy 69e201f927 fix(h5-partner): 门店套餐编辑按钮改为紧凑型并贴右对齐
CI / verify (push) Has been cancelled
2026-08-16 12:22:54 +08:00
jacy ecd9973d8f feat(admin): 套餐变更对比逐字差异定位 + 开发计划并入v3.4.17
CI / verify (push) Has been cancelled
2026-08-16 12:17:34 +08:00
jacy bbd7e52e70 feat(admin): 套餐变更对比逐字段列出变动明细(v3.4.18)
CI / verify (push) Has been cancelled
2026-08-16 12:09:37 +08:00
jacy 1c91e6bae9 v3.4.17 门店套餐审核快捷入口
CI / verify (push) Has been cancelled
2026-08-16 11:57:01 +08:00
jacy 029fd690a5 merge(dev): fix(admin) 套餐审核导航样式恢复一致
CI / verify (push) Has been cancelled
2026-08-16 08:56:41 +08:00
jacy 0bfc19b1a4 merge(dev_jacy): fix(admin) 套餐审核导航样式恢复一致 2026-08-16 08:56:28 +08:00
jacy bafb342600 fix(admin): 套餐审核导航恢复与子菜单一致的样式
- 移除对「套餐审核」文字的强制白色处理,文字随菜单主题正常继承 normal/hover/selected 颜色
- 徽章仅作为待审数量提示图标,pendingCount>0 时才显示,不参与文字着色

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-16 08:56:13 +08:00
jacy bac5c7ad80 merge(dev): 联系电话脱敏支持座机 + 套餐表单多行输入
CI / verify (push) Has been cancelled
2026-08-16 08:52:19 +08:00
jacy c05cd0e96d merge(dev_jacy): 联系电话脱敏支持座机 + 套餐表单多行输入 2026-08-16 08:52:01 +08:00
jacy 8de41796fb feat(admin,partner,shop,mini,domain): 联系电话脱敏支持座机 + 套餐表单多行输入
- maskPhone/maskContactPhone 支持座机(含区号/分机)脱敏展示,domain 补充单测
- 套餐「使用时间/其他说明」由单行 input 改为多行 textarea(admin/h5-partner/h5-shop)
- 后台「套餐审核」标签文字恒为白色(不受 Badge 角标影响)
- 新增 deploy/sync-prod-db-to-local.sh:线上库经 SSH 隧道同步至本地(含 @ 密码/base64 解析、--lock-tables=0 兼容受限账号)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-16 08:51:43 +08:00
jacy 4b3b1f76ae merge(dev): 座机联系电话 + 套餐审核体验
CI / verify (push) Has been cancelled
2026-08-14 18:45:24 +08:00
jacy 75d820b574 merge(dev_jacy): 座机联系电话 + 套餐审核体验 2026-08-14 18:45:17 +08:00
jacy 0f48d7abda feat(admin,store): landline contact phone and package audit UX
Allow store contactPhone as landline, show pending package audit badge, and split live vs pending packages in HQ review.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 18:45:10 +08:00
jacy 4a0e18472b merge(dev): 用户权益列 + 门店合同多图上传(需求1/2/3)
CI / verify (push) Has been cancelled
2026-08-12 22:50:53 +08:00
jacy 491f03e825 merge(dev_jacy): 用户权益列 + 门店合同多图上传(需求1/2/3) 2026-08-12 22:50:33 +08:00
jacy 88053353ab feat(ops,store,partner): 用户权益列 + 门店合同多图上传
- admin 用户列表新增剩余/已用/累计好客权益金额三列(benefitCoupon groupBy 聚合,排除 VOID)
- admin/partner 门店入驻合同支持多张照片与 PDF(CommonResource 多记录,复用 ENV 多图逻辑)
- 新增 contract-urls.util 归一化工具,向后兼容旧 contractUrl 字段并标记 deprecated

需求1/2/3
2026-08-12 22:48:02 +08:00
jacy ceacce9ed5 merge(dev): admin contract/package/winery fixes
CI / verify (push) Has been cancelled
2026-08-10 20:31:09 +08:00
jacy 3913bf3bf3 merge(dev_jacy): admin contract/package/winery fixes 2026-08-10 20:31:05 +08:00
jacy 478291e2b3 fix(admin): contract preview, package audit detail, empty packages, winery status
Allow HQ to save stores with zero packages, preview contract images/PDFs, inspect package audit images/content, and show gray 无需打款 when winery payable is 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:30:55 +08:00
jacy 6754caf076 merge(dev): v3.4.16 partner UX and CS gate
CI / verify (push) Has been cancelled
2026-08-09 16:01:04 +08:00
jacy 92c3175acf merge(dev_jacy): v3.4.16 partner UX and CS gate 2026-08-09 16:00:57 +08:00
jacy 46361ec713 feat(v3.4.16): partner UX, package pricing, CS gate, HQ list polish
Stop auto ST from validation_error; show package prices with right-aligned layout; partner store list status/filter and onboard CS QR gate; HQ store table truncation/fixed actions; expose CS config in wechat_mini settings; bind local servers for LAN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 16:00:45 +08:00
jacy 5586ed90cd merge(dev): store contactPhone separate from login
CI / verify (push) Has been cancelled
2026-08-09 07:50:53 +08:00
jacy 4cec5b1fe5 merge(dev_jacy): store contactPhone separate from login 2026-08-09 07:50:50 +08:00
jacy 07630c9046 feat(store): separate login phone from public contactPhone
Store.phone stays login/boss; contactPhone is C-end dial. Public store APIs keep phone compat via publicDial mapping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 07:50:40 +08:00
jacy 4488b0c006 merge(dev): save packages with store 保存修改
CI / verify (push) Has been cancelled
2026-08-07 21:36:40 +08:00
jacy 99c74f20d2 merge(dev_jacy): save packages with store 保存修改 2026-08-07 21:36:38 +08:00
jacy a598d1cd30 fix(admin): save packages when clicking store 保存修改
HQ users were uploading package images then clicking drawer 保存修改, which only persisted store media and never PUT packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 21:36:28 +08:00
jacy da6a38daa4 merge(dev): fix batch image upload persistence
CI / verify (push) Has been cancelled
2026-08-07 21:13:45 +08:00
jacy c5926bd593 merge(dev_jacy): fix batch image upload persistence 2026-08-07 21:13:42 +08:00
jacy df546ff39f fix(admin): keep all batch-uploaded images when saving packages
Serialize multi-file uploads and reload package list after save so imageUrls persist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 21:13:31 +08:00
jacy d6d4a77473 merge(dev): local API port 3010
CI / verify (push) Has been cancelled
2026-08-07 21:04:06 +08:00
jacy 0518976953 merge(dev_jacy): local API port 3010 2026-08-07 21:03:59 +08:00
jacy 8f83624ffe chore(dev): move local API default port to 3010
Avoid conflict with another local service on 3000; align Vite proxies and docs.
2026-08-07 21:03:23 +08:00
jacy 7c6dae472c merge(dev): persist package imageUrls on approve
CI / verify (push) Has been cancelled
2026-08-07 20:46:39 +08:00
jacy 209db59422 merge(dev_jacy): persist package imageUrls on approve 2026-08-07 20:46:36 +08:00
jacy 21d44026d9 fix(store): persist package imageUrls on audit approve
Ensure multi-image packages are written on approval and always returned as arrays from store detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 20:46:26 +08:00
jacy 3b192b0b88 merge(dev): store sortOrder and package gallery swipe
CI / verify (push) Has been cancelled
2026-08-07 19:58:53 +08:00
jacy 1b3a7f3a36 merge(dev_jacy): store sortOrder and package gallery swipe 2026-08-07 19:58:50 +08:00
jacy 34a1897c09 feat(store): add store sortOrder and swipeable package gallery
Enable HQ-controlled store list ordering and horizontal multi-image browsing on package detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:58:38 +08:00
jacy 36017a19b1 merge(dev): batch image upload for package/env/product
CI / verify (push) Has been cancelled
2026-08-07 19:41:12 +08:00
jacy 9c7550a1c4 merge(dev_jacy): batch image upload for package/env/product 2026-08-07 19:41:10 +08:00
jacy fe1c6c8158 feat(admin): batch upload for package, store env, and product images
Allow multi-select uploads in HQ, partner, and shop for the three image galleries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:40:59 +08:00
jacy acc841bb21 merge(dev): v3.4.15 store media edit and package multi-image
CI / verify (push) Has been cancelled
2026-08-07 19:24:20 +08:00
jacy 61a99aeea9 merge(dev_jacy): v3.4.15 store media edit and package multi-image 2026-08-07 19:24:17 +08:00
jacy 76375eff83 feat(v3.4.15): HQ store media edit and package images up to 20
Allow HQ to replace/delete store photos; support multi-image packages with a 20-image cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:24:07 +08:00
jacy 713dce52b4 merge(dev): parse XFX multipart track callbacks
CI / verify (push) Has been cancelled
2026-08-07 19:08:15 +08:00
jacy e41bf838d4 merge(dev_jacy): parse XFX multipart track callbacks 2026-08-07 19:08:12 +08:00
jacy 0692bebf07 fix(courier): parse XFX multipart/form-data track callbacks
Xiaofeixia posts multipart form fields; previously mis-read as urlencoded empty payload. Parse multipart text parts and map into order status transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:08:03 +08:00
jacy b19f875e7b merge(dev): fix XFX callback build types
CI / verify (push) Has been cancelled
2026-08-07 18:58:56 +08:00
jacy 090002b6c0 merge(dev_jacy): fix XFX callback build types 2026-08-07 18:58:54 +08:00
jacy 8726cf14e3 fix(courier): cast IncomingMessage when skipping XFX body parsers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:58:45 +08:00
jacy 7298b1a5d5 merge(dev): log XFX callback rawBody to third-party logs
CI / verify (push) Has been cancelled
2026-08-07 18:57:43 +08:00
jacy 1401f1dcd2 merge(dev_jacy): log XFX callback rawBody to third-party logs 2026-08-07 18:57:41 +08:00
jacy 155426b669 fix(courier): log XFX track callback rawBody and Content-Type
Capture raw request for courier track callbacks regardless of Content-Type and persist contentType/rawBody/query into log_third_party for empty-body diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:57:31 +08:00
jacy b4d6f2bd91 merge(dev): v3.4.14 test whitelist and SUPER_ADMIN fix
CI / verify (push) Has been cancelled
2026-08-07 15:46:47 +08:00
jacy 029c17347e merge(dev_jacy): v3.4.14 test whitelist and SUPER_ADMIN fix 2026-08-07 15:46:40 +08:00
jacy b626db5d84 feat(ops): add global test whitelist and exclude test accounts from settlement
Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
2026-08-07 15:46:23 +08:00
jacy d98ef6b808 merge(dev): v3.4.15 admin track and store list
CI / verify (push) Has been cancelled
2026-08-07 13:26:05 +08:00
jacy d13a6ee3cd merge(dev_jacy): v3.4.15 admin track and store list 2026-08-07 13:26:03 +08:00
jacy 10fa361983 feat(v3.4.15): admin delivery track drawer and mini-user store list polish
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 13:25:55 +08:00
jacy 1a615ffe93 merge(dev): harden iOS WeChat shop scan
CI / verify (push) Has been cancelled
2026-08-07 13:08:22 +08:00
jacy 0269802e59 merge(dev_jacy): harden iOS WeChat shop scan 2026-08-07 13:08:20 +08:00
jacy e4e9eb2169 fix(h5-shop): harden iOS WeChat scan after login with hard nav and recover UI
Root cause is JSSDK entry-URL mismatch after SPA post-OAuth, not camera permission. Hard-navigate on iOS, keep OAuth query in sign URL, skip redundant bind OAuth, and prompt refresh/re-auth on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 13:08:13 +08:00
jacy b5fb9a0bb2 merge(dev): fix iOS shop scan JSSDK entry-URL signing
CI / verify (push) Has been cancelled
2026-08-07 12:54:01 +08:00
jacy 9d800a4cbd merge(dev_jacy): fix iOS shop scan JSSDK entry-URL signing 2026-08-07 12:53:59 +08:00
jacy 2a9493165b fix(weixin-sdk): restore iOS JSSDK entry-URL signing for shop scan
Phone re-login + OAuth on iOS left SPA on a different path than the WebView entry URL, so scanQRCode failed until a full reopen. Sign with the document entry URL again and warm up after auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 12:53:52 +08:00
jacy b32c68d56b merge(dev): fix XFX track callback body parse
CI / verify (push) Has been cancelled
2026-08-06 17:51:04 +08:00
jacy 6816835fb8 merge(dev_jacy): fix XFX track callback body parse 2026-08-06 17:51:02 +08:00
jacy 571959900b fix(courier): parse XFX form-urlencoded track callbacks and raw ACK
Enable urlencoded body parser, return XFX {code,message} without API envelope, and harden callback payload parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 17:51:00 +08:00
jacy 7bc710f139 merge(dev): mini-user share config in system settings
CI / verify (push) Has been cancelled
2026-08-06 15:46:07 +08:00
jacy 397bdad73f merge(dev_jacy): mini-user share config in system settings 2026-08-06 15:46:05 +08:00
jacy 83b1b0e5f6 feat(mini-user): configurable WeChat mini share scenes in system settings
Add HQ group for default and per-page share title/desc/image; expose via client-config and wire all mini-user share entry points.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:45:57 +08:00
jacy 91fed31b10 merge(dev): v3.4.14 mini-user store UX and brand config
CI / verify (push) Has been cancelled
2026-08-06 15:22:15 +08:00
jacy 1933b9b608 merge(dev_jacy): v3.4.14 mini-user store UX and brand config 2026-08-06 15:22:12 +08:00
jacy a2abbb6169 feat(v3.4.14): mini-user store UX, brand config, client settings
Store list/detail package flow, configurable WeChat mini brand assets and customer service, shop H5 home refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:21:59 +08:00
jacy 53a79d1255 压缩文档 2026-08-06 01:09:19 +08:00
jacy 0181af09c5 merge(dev): v3.4.13 partner column, release cascade, client UX
CI / verify (push) Has been cancelled
2026-08-06 01:02:29 +08:00
jacy 9cd0c9508a merge(dev_jacy): v3.4.13 partner column, release cascade, client UX 2026-08-06 01:02:15 +08:00
jacy 4f363fa9e7 feat(v3.4.13): partner column, version release cascade, client UX fixes
- HQ stores: partner column uses companyName/name/phone via partnerOptionLabel

- Dev plan: version RELEASED auto-marks tasks RELEASED and tickets PUBLISHED with releasedVersionNo

- mini-user/h5-shop/h5-partner v3.4.13 UX fixes (store UI, iOS scan OAuth, partner login guard)

- Docs: v3.4.13 dev doc + status audit updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 01:02:07 +08:00
jacy 291137a15d merge(dev): admin redeem detail UI (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:27:21 +08:00
jacy e2a5f21f9d merge(dev_jacy): admin redeem detail UI (v3.4.13) 2026-08-06 00:27:18 +08:00
jacy 3ef4432b2f feat(admin-web): widen benefit coupon detail and enrich redeem record drawer (v3.4.13)
Share redeem detail fields across benefit coupon and redeem records pages;
widen coupon drawer to avoid horizontal scroll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:27:11 +08:00
jacy 33b868dfac merge(dev): ticket center status Chinese labels
CI / verify (push) Has been cancelled
2026-08-06 00:23:43 +08:00
jacy 5c4ad1d8db merge(dev_jacy): ticket center status Chinese labels 2026-08-06 00:23:41 +08:00
jacy a346fae7a4 fix(admin-web): show Chinese labels for ticket center status
Add TICKET_STATUS_LABELS and use Tag in list, detail and filter on TicketsPage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:23:33 +08:00
jacy 37bc297d1a merge(dev): mini-user version display and force exit (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:15:06 +08:00
jacy bc1616c3f7 merge(dev_jacy): mini-user version display and force exit (v3.4.13) 2026-08-06 00:15:03 +08:00
jacy 46a200e0ba feat(mini-user): show version on mine page and force exit when below min (v3.4.13)
Display v3.4.x next to brand on mine page; below minClientVersion prompt update via UpdateManager or exit mini program after acknowledge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:14:53 +08:00
jacy 429eaabe6b merge(dev): promo metric logs and HQ timeline (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:07:02 +08:00
jacy 607a0224cd merge(dev_jacy): promo metric logs and HQ timeline (v3.4.13) 2026-08-06 00:06:59 +08:00
jacy 53e0db6a98 feat(promo): promo metric event logs and HQ timeline charts (v3.4.13)
Add log_promo_event for scan/attribution/register/order with IP; admin metrics APIs and ECharts on promo detail page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:06:52 +08:00
jacy 456b3af461 merge(dev): courier signed callback completes order (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:46:47 +08:00
jacy 1f7ecffe4c merge(dev_jacy): courier signed callback completes order (v3.4.13) 2026-08-05 23:46:44 +08:00
jacy 8df0a2bea7 feat(trade): complete order on courier signed callback (v3.4.13)
When XFX route callback reports status=5 or statusName containing 签收, map to COMPLETED instead of PENDING_RECEIVE; document in v3.4.13 docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:46:37 +08:00
jacy 672a995c59 fix(mini-user): split order-logistics utils for weapp bundle stability
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:41:31 +08:00
jacy 69f52921a7 merge(dev): v3.4.13 logistics enhancements
CI / verify (push) Has been cancelled
2026-08-05 23:34:20 +08:00
jacy 9cb88dc181 merge(dev_jacy): v3.4.13 logistics enhancements 2026-08-05 23:34:17 +08:00
jacy 345d5db9c9 feat(mini-user): logistics sign photos, dial, ETA and courier callbacks (v3.4.13)
Extend Courier adapter for XFX sign photos and route callbacks; mini-user logistics UI with timeline, phone dial, and estimated arrival.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:34:09 +08:00
jacy 7c9240fc84 merge(dev): logistics detail page polish (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:14:28 +08:00
jacy 2610288df0 merge(dev_jacy): logistics detail page polish (v3.4.13) 2026-08-05 23:14:26 +08:00
jacy 62652cd072 fix(mini-user): polish logistics detail page layout and timeline order (v3.4.13)
Use custom navigation bar, remove delivery status section, and show newest track nodes first.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:14:18 +08:00
jacy 9d49ed5e70 merge(dev): mini-user logistics preview and detail timeline (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:03:58 +08:00
jacy bdbd135318 merge(dev_jacy): mini-user logistics preview and detail timeline (v3.4.13) 2026-08-05 23:03:49 +08:00
jacy 8ca893efab feat(mini-user): show shipping logistics preview and detail timeline (v3.4.13)
Order detail displays latest XFX route under 配送中 with link to full timeline page; docs updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:03:41 +08:00
jacy d0ab838da6 merge(dev): v3.4.13 upload compression and winery T+3 fix
CI / verify (push) Has been cancelled
2026-08-05 22:48:47 +08:00
jacy 50ab0f1748 merge(dev_jacy): v3.4.13 upload compression and winery T+3 fix 2026-08-05 22:48:44 +08:00
jacy fa6fb80d19 feat(upload): compress images over 10MB before OSS upload (v3.4.13)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:48:34 +08:00
jacy bd1930f6b1 fix(settlement): align winery bill T+3 window to order completedAt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:36:51 +08:00
jacy dc4295d1bd merge(dev): v3.4.13 experience optimizations
CI / verify (push) Has been cancelled
2026-08-05 22:34:44 +08:00
jacy 256526c5ec merge(dev_jacy): v3.4.13 experience optimizations 2026-08-05 22:34:40 +08:00
jacy 1c4b986924 feat(release): v3.4.13 experience optimizations across admin, mini-user, and H5 login
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:34:29 +08:00
jacy bf501d2374 merge(dev): support ticket batch select and task bind
CI / verify (push) Has been cancelled
2026-08-05 08:08:27 +08:00
jacy 442500b1a1 merge(dev_jacy): support ticket batch select and task bind 2026-08-05 08:08:24 +08:00
jacy 490c26a7e1 feat(admin): unrestricted support ticket batch select and task ticket bind
Allow selecting support tickets of any status for batch actions; add optional support ticket binding when creating a dev plan task.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 08:08:17 +08:00
jacy b40eb83f27 Merge pull request 'Dev' (#13) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/13
2026-08-05 00:57:33 +08:00
jacy f80a0f2c27 Merge pull request '修改门店列表组件' (#12) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/12
2026-08-05 00:57:14 +08:00
jacy 528669f662 修改门店列表组件
CI / verify (pull_request) Has been cancelled
2026-08-05 00:55:58 +08:00
jacy f5e523a425 merge(dev): mini-user store list card redesign
CI / verify (push) Has been cancelled
2026-08-05 00:11:34 +08:00
jacy 16fa07ceaf feat(mini-user): redesign store list with square thumbnail cards
Switch store list to horizontal card layout with square cover image, status badge, hours, address and solid redeem CTA per design mockup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:11:25 +08:00
jacy b21403a47c merge(dev): kb doc edit, validation auto-tickets, support batch ops
CI / verify (push) Has been cancelled
2026-08-05 00:09:07 +08:00
jacy 02d89e6385 feat(ops): kb doc edit, validation auto-tickets, support batch ops
Add knowledge document GET/PUT and admin edit UI; auto-create support tickets on client 400 validation errors across user/shop/partner apps; batch create tasks and publish from support tickets; restore mini-user store env single-column layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:08:40 +08:00
jacy f9a5b6e81a merge(dev): fix h5-shop upload export for v3.4.12
CI / verify (push) Has been cancelled
2026-08-04 23:54:22 +08:00
jacy 16c386f165 fix(h5-shop): export uploadFileToOss for package image upload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:54:20 +08:00
jacy 1c787ed576 merge(dev): release v3.4.12
CI / verify (push) Has been cancelled
2026-08-04 23:53:48 +08:00
jacy 4372018c09 feat: v3.4.12 ticket iteration
Refund rollback, winery T+3, multi withdraw, mini-user store detail, dev plan batch edit and WeCom dispatch, support ticket edit/attachments/batch status, package imageUrl.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:53:31 +08:00
687 changed files with 45911 additions and 31677 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`
+3
View File
@@ -39,6 +39,9 @@ src/
## UI 约束 ## UI 约束
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
- C 端订单 **5 Tab**(含 pending_ship - C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态 - 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md` - 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
+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,可保留。
@@ -0,0 +1,46 @@
---
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
alwaysApply: false
---
# mini-user · 微信 open-type 硬规则
## 禁止(必踩坑)
**现象**`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
```tsx
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
<View className="mask" onClick={close}>
<View className="sheet" onClick={(e) => e.stopPropagation()}>
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
</View>
</View>
```
## 正确写法
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
```tsx
// ✅
<View className="mask">
<View className="backdrop" onClick={close} />
<View className="sheet">
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
...
</Button>
</View>
</View>
```
## 附加
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
+2 -2
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
@@ -89,7 +89,7 @@ await this.prisma.order.update({ ... });
```typescript ```typescript
const benefitAmount = product.benefitAmount ?? product.price; const benefitAmount = product.benefitAmount ?? product.price;
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额 // 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
// 同城 min 2 瓶 / 跨城 min 6 瓶 // 起购:提货/同城 min 2 瓶 / 跨城 min 6 瓶(城市配置,非承运商)
// 支付成功 → log_third_party + user_order.pay_status=PAID // 支付成功 → log_third_party + user_order.pay_status=PAID
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT) // → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
@@ -13,7 +13,7 @@
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。 > **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
Vite 代理:`/api``localhost:3000` Vite 代理:`/api``localhost:3010`(可用 `VITE_API_TARGET` 覆盖)
## 共享包 ## 共享包
+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
+1 -1
View File
@@ -37,7 +37,7 @@ disable-model-invocation: true
``` ```
权益额 = 订单实付 1:1(永久) 权益额 = 订单实付 1:1(永久)
同城起购 2 瓶 / 跨城 1 箱(6瓶) 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商
门店结算 = 核销额 × 60% 门店结算 = 核销额 × 60%
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%) 单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
核销码 TTL = 3 分钟;试核销固定 ¥100 核销码 TTL = 3 分钟;试核销固定 ¥100
@@ -12,9 +12,9 @@
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 | | REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 | | REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 | | REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 | | REQ-U-007 | 同城 | 小飞侠;起购 ≥2 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 | | REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 |
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 | | REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 |
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 | | REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
| REQ-U-011 | 权益 | 支付成功 1:1 永久 | | REQ-U-011 | 权益 | 支付成功 1:1 永久 |
| REQ-U-012 | 客服 | 已付款起电话/微信客服 | | REQ-U-012 | 客服 | 已付款起电话/微信客服 |
+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/
+45
View File
@@ -0,0 +1,45 @@
# 2026-08-12 工作记录
## 杜康好客 · 三项功能需求落地(dev_jacy 分支)
### 需求1:管理端用户列表增加好客权益金额列
- 后端 `server/dukang-api/src/modules/ops/admin-users.service.ts`
- 新增常量 `BENEFIT_STAT_STATUSES = ['ACTIVE','USED_UP']``UserBenefitStat` 类型、`EMPTY_BENEFIT_STAT``toAmount()`
- `list()` / `detail()` 均调用新增私有方法 `loadBenefitStats(userIds)`,用 `benefitCoupon.groupBy({ by:['userId'], where:{status:{in:[...BENEFIT_STAT_STATUSES]}}, _sum:{totalAmount,usedAmount,balance} })` 批量聚合,避免 N+1。
- `mapAdminUserRow` 增加 `benefit` 参数并展开到返回对象。统计口径与 C 端 `listCoupons` 一致(排除 VOID)。
- 前端 `apps/admin-web/src/lib/api.ts``AdminUserRow` 增加 `benefitTotalAmount?/benefitUsedAmount?/benefitBalance?: number`
- 前端 `apps/admin-web/src/pages/UsersPage.tsx`:新增「剩余权益/已用权益/累计权益」三列(右对齐、可排序,scroll x 改为 1830),详情抽屉 Descriptions 增加「好客权益」项。
### 需求2:管理端门店入驻合同支持多图上传
- 新建共享工具 `server/dukang-api/src/common/store-media/contract-urls.util.ts``MAX_CONTRACT_FILES=20``normalizeContractUrls(urls?, legacy?)``contractMediaType(url)`.pdf→FILE 否则 IMAGE)。
- 后端 `admin-stores.service.ts`import 上述工具;create645 行)/ update451 行)路径由单条 `contractUrl` 改为遍历建立多条 `bizType:'CONTRACT'` 记录(`sortOrder:i``mediaType` 按扩展名判定)。
- DTO `admin-mutate.dto.ts``contractUrl?:string` 标记 `@deprecated`,新增 `contractUrls?:string[]``@IsArray @IsString({each:true}) @ArrayMaxSize(20)`)。
- 前端 `apps/admin-web/src/lib/storeCreate.ts``StoreCreateForm.contractUrl?:string``contractUrls?:string[]`
- `MultiImageUpload.tsx`:新增 `isPdf()``FilePdfOutlined`、PDF 缩略图卡片、`buttonText` prop。
- `StoresPage.tsx`:编辑抽屉 + 创建 step1 表单均改用 `MultiImageUpload`name=contractUrls, maxCount=20, accept=image/*,.pdf, buttonText="批量上传合同"),回显/保存 payload 改为 `contractUrls:string[]`
### 需求3:合伙人 H5 门店入驻合同支持多图上传
## 发布流水线(同一会话,22:45 后)
完整跑通 dukang-release 标准流程:`dev_jacy` → push → merge `dev` → merge `main` → 发生产。
- 提交 `8805335` feat(ops,store,partner): 用户权益列 + 门店合同多图上传(13 文件,356+/59-
- `dev_jacy` push → `origin/dev_jacy`(8805335)
- merge `dev_jacy``dev``491f03e`push `origin/dev`
- merge `dev``main``4a0e184`push `origin/main`
- `deploy/deploy-prod.sh -- --skip-db`(无 Prisma schema 变更,故 --skip-db)→ `DEPLOY_EXIT=0``==> 发版完成 (production)`
- 生产 PM2 全 online`dukang-api`(8090) / admin-web / h5-partner / h5-shop / h5-user;健康检查 `api:200` `mini-user(h5):200`;版本记录 commit=4a0e184
### 踩坑/注意
- `git checkout main` 时本机工作树出现 `server/dukang-api` 大量 `D`(误删)假象:实为 sandbox 下 checkout 未刷新工作树,非仓库损坏。用 `git checkout -f main` 重新填充即恢复;`origin/main` 始终正确(ls-remote 核对 4a0e184)。
- 提交/合并后 `git status` 报 "ahead of origin by N" 为本地 tracking ref 陈旧,非真实分歧(ls-remote 已核对远程三分支均含本次提交)。
- 仅提交 13 个需求文件 + 新建 `server/dukang-api/src/common/store-media/`,已排除 `.workbuddy/``fix_acl.ps1`
- 发布命令在 sandbox 下直接运行 SSH 部署成功(未强制禁用沙箱)。
- `apps/h5-partner/src/lib/storeDraft.ts``StoreDraftForm.contractUrl:string``contractUrls:string[]``defaultStoreForm` 改为 `contractUrls:[]`;新增 `MAX_CONTRACT_COUNT=20``normalizeStoreDraftForm` 新增 `contractUrls` 计算(兼容旧 `contractUrl` 迁移为数组,去重限 20);`validateStoreStep2` 参数/校验改为 `contractUrls`,不足 1 张返回「请上传签约合同」。
- `MultiOssUploadField.tsx`:新增 `accept?``unit?` props`isPdf()`PDF 缩略图卡片。
- `StoreCreatePage.tsx`:签约合同区块 `OssUploadField``MultiOssUploadField`value=`form.contractUrls ?? []`, maxCount=MAX_CONTRACT_COUNT, unit="个", accept="image/*,.pdf");提交体 `contractUrl``contractUrls`,新增 `const contractUrls = Array.from(new Set(...)).slice(0, MAX_CONTRACT_COUNT)`envPhotoUrls slice 上限由 3 改为 20。修正了渲染块误用未定义变量 `contractUrls`(已改为从 form 读取)。
### 验证
- 后端 `npx tsc --noEmit`server/dukang-api)通过,exit 0。
- admin-web / h5-partner 使用 `git stash` 基线对比法:本次改动**未引入任何新类型错误**(现存错误均为预存在基线问题:css module 声明、`import.meta.env`、过期 config 类型、TabLayout `end`、staff `staffRole` 等)。
- 向后兼容:旧 `contractUrl` 字段保留并标记 deprecated,H5 草稿旧数据自动迁移;避免破坏现有 API 客户端。
+79
View File
@@ -0,0 +1,79 @@
# 2026-08-16
## 新增:线上库同步本地 CLI 工具
- 文件:`deploy/sync-prod-db-to-local.sh`(新建,未提交)
- 用途:将生产库 `dukang_prod`(阿里云 RDS `rm-bp139andp1aa385i9.mysql.rds.aliyuncs.com:3306`,用户 `dukangadmin`)同步到本地 Docker MySQL `dukang_haoke`localhost:6016 / 容器内 `dukang-v1-mysql`root/root)。
- 原理:SSH 隧道借道 `dukang-server``-L 6018:rds:3306`)→ 本地无 mysql 客户端,故在 `dukang-v1-mysql` 容器内跑 mysqldump/mysql,经 `host.docker.internal:TUNNEL_PORT` 连隧道;管道直导,不落临时文件。
- 安全:默认先 `docker exec mysqldump` 备份本地到 `deploy/backups/`;需 `--yes` 跳过交互确认;`--dry-run` 只读远端 DATABASE_URL 并打印计划。
- 远端凭据从 `/opt/dukang/server/dukang-api/.env.production` 的 DATABASE_URL 运行时解析(密码经 `MYSQL_PWD` 环境变量传入,避免命令行暴露)。
- 已知限制:不支持密码含 `@`prod DB 名与本地不同(dukang_prod→dukang_haoke)靠 mysqldump 不带 `--databases` 实现库名映射。
- 状态:仅跑通 `--dry-run`,未执行真实同步(待用户确认,因会覆盖本地库并拉取生产数据)。
## 修复:首次同步失败的根因
- 失败现象:`mysqldump` 退出码 2,提示 `本地库可能处于不一致状态`(工具自身的失败兜底提示)。
- 真实根因有两条:
1. **密码解析错误**:生产 DB 密码含 `@` 字符(`passHasAt=true`)。原 bash 解析用「第一个 @」切分 userinfo/host,把密码里 `@` 之后的部分截断,导致 Access denied。
修复:改为在服务端用 `new URL()` 解析,base64 回传各字段(user/host/port/db/password),本地 `base64 -d` 还原,彻底免疫 `@`/`:`/`$` 等特殊字符。服务端 auth 测试 `authTestStatus=0` 验证密码正确。
2. **权限不足**`dukangadmin`(应用库用户)无 `RELOAD`/`FLUSH_TABLES` 权限。`--single-transaction` 仍会触发全局 `FLUSH TABLES` → Access denied。
修复:`mysqldump` 改用 `--lock-tables=0`(不发起 FTWRL+ `--set-gtid-purged=OFF`(RDS 启用 GTID,否则本地恢复会报 GTID_PURGED 错误),并保留 `--skip-routines --skip-events --column-statistics=0 --no-tablespaces --add-drop-table --skip-triggers --no-create-db`
- 验证:结构导出(--no-data)在 4 组 flag 中仅 `--lock-tables=0` / `--skip-lock-tables`(不含 --single-transaction)退出 0。
- 现工具已修正并实际执行同步(--yes,先自动备份本地到 deploy/backups/)。
- 注意:`--lock-tables=0` 无事务一致性快照;生产库在线写入期间导出可能有极小不一致,但恢复时 mysqldump 自带 `FOREIGN_KEY_CHECKS=0`,_dev 库可接受。若需完全一致快照,可给 dukangadmin 授 RELOAD 后用 --single-transaction。
## 修改:套餐审核导航样式恢复一致
- 文件:`apps/admin-web/src/layouts/AdminLayout.tsx``attachPackageAuditBadge``/store-package-audits` 项)。
- 诉求演变:用户先要求「文字恒为白色」→ 后改为「不要单独处理,保持与子菜单一致,只是多一个待审徽章图标」。
- 最终实现:label 改为 `<span style={{display:'inline-flex',gap:6}}>套餐审核{pendingCount>0 && <Badge count size="small"/>}</span>`。文字无强制颜色,随菜单主题继承 normal/hover/selected 颜色;徽章仅 pendingCount>0 时作为图标提示,不参与着色。
## 发布流水线(本次收尾,含之前未上线的 8+1 文件)
- 背景:此前「联系电话脱敏+套餐多行输入+同步工具」等 9 文件 commit `8de4179` 已 push dev_jacy/dev/main,但**生产发布失败**deploy.env 丢失导致 `未配置 DEPLOY_HOST`)。
- 修复发布:`deploy.env` 是 gitignore 的本地密钥文件,已丢失不可找回;改用 SSH 别名 `dukang-server`~/.ssh/configHostName 47.110.129.57 / User root / IdentityFile pem)覆盖:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`
- 关键排查:`origin/*` 本地缓存 ref 多次陈旧(误报 ahead/behind),必须以 `git ls-remote` 为准。本次真实远端:dev_jacy=8de4179、dev=c05cd0e、main=bac5c7a,与本地一致,无丢失。
- 本次提交:`bafb342` fix(admin) 套餐审核样式 → push dev_jacy → merge dev(`0bfc19b`) → merge main(`029fd69`) → push。
- 发布结果:`DEPLOY_EXIT=0``==> 发版完成 (production)`,健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `029fd69`
- 提示:以后若想恢复标准 `bash deploy/deploy-prod.sh`(不带 --host),需重建 `deploy/deploy.env`(从 `deploy.env.example` 复制,填入 DEPLOY_HOST=47.110.129.57 等;该文件 gitignore,勿提交)。
## 排查:h5-partner 本地 /login 调 /partner/me 报 401
- 现象:`http://localhost:5176/login` 启动后控制台报 `GET /api/v1/partner/me 401 (Unauthorized)`
- 结论:**不是 bug,是登录页的「过期 token 探测」**。链路:Vite 代理 `/api``localhost:3010`(正常);后端返回 `{"code":401,"message":"Missing token"}`(说明后端在线、代理通)。`ensureSession()`api.ts:244)只在 localStorage 有 token 时才打 `/partner/me`;无 token 直接返回未登录。所以 401 来自之前测试残留的失效 token → 前端静默清除并展示登录表单,不崩、不弹错。刷新页面(token 已清)即不再出现。
- 本地登录方式(关键,README 已过期):
- `MOCK_SMS=true`(.env),短信走 mock,不连运营商;固定码 `MOCK_SMS_FIXED_CODE` 默认 `999888`README 写的 123456 已不准)。
- README 的测试号 `13700000001` **不在本地同步库**;本地 `partner_account` 共 9 个,可用真实号如 `18049821889`(Jacy) / `13073729990`(queen)(均 ACTIVE)。
- 流程:填真实合伙人手机号 → 获取验证码 → **验证码打印在 `pnpm dev:api` 后端终端**`Mock SMS → 180****8889 code=XXXXXX`)→ 填码登录。
- 注意 h5-partner 默认 5175,本次因 5175 被占自动落到 5176,代理是路径匹配不受影响。
## 发布:v3.4.17 门店套餐审核快捷入口 上生产
- 提交 `1c91e6b`dev_jacy 上,含后端 admin-stores.service.ts + AdminStorePackagesSection/StorePackageAuditsPage/StoresPage 三前端 + 开发计划文档)。
- 流水线:直接快进推送(避免 git checkout 工作树不刷新):`git push origin dev_jacy` + `dev_jacy:dev` + `dev_jacy:main`,三分支统一到 `1c91e6b`;本地 `git branch -f dev/main origin/*` 同步。
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(后端仅给列表响应加字段,无 schema 变更,故 skip-db)。
- 结果:`DEPLOY_EXIT=0`,新 tag `v3.4.17`,全部应用构建通过,PM2 重启 dukang-api/h5-shop/h5-partner/admin-web(另拉起 h5-user),健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `1c91e6b`
## 发布:v3.4.18 套餐变更对比逐字段列出变动明细
- 需求:套餐变更对比「将变动的地方详细列出来」(之前只给「新增/删除/变更/未变」粗粒度标记,不列具体字段)。
- 改动仅在 `apps/admin-web/src/pages/StorePackageAuditsPage.tsx`
- 新增 `fieldChanges(live, proposed)`:逐字段比对 价格/套餐名称/菜品内容/可用时间/其他说明/图片,产出 `label+旧值→新值` 明细。
- `diffPackages` 给「变更」行附 `changes`
- `PackageDetailCard` 在「待审核」卡片底部渲染「变更明细」块(旧值删除线 → 新值加粗,橙色块)。
- 抽屉顶部加「新增 X · 删除 Y · 变更 Z · 未变 W」汇总条。
- 提交 `bbd7e52`feat(admin) 套餐变更对比逐字段列出变动明细),快进推送 dev_jacy/dev/main。
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(无 schema 变更)。
- 结果:构建通过、PM2 重启 admin-web 等、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `bbd7e52`(无精确 tag,记 tag=-,无害)。
- 已知局限:配对按「套餐名称」(空则索引) 位置匹配;若套餐被**改名**,会误判为「删除旧名+新增新名」而非「变更」,故名称变更不会出现在逐字段明细里。
## 优化:套餐变更对比·逐字差异 + 文档并入 v3.4.17
- 需求:变动明细要能**定位到文本中具体哪些字变了(逐字检查)**,且把开发计划文档合并到 v3.4.17 文档里(不再单独留 v3.4.18 文档)。
- 代码改动(仍仅 `StorePackageAuditsPage.tsx`):
- `diffText(a,b)`LCS 动态规划 (`dp[i][j]`)+回溯,产出 `equal/delete/insert` 段落并合并相邻同类型;`O(|a|·|b|)`,字段长度可控。
- `TextDiff({oldText,newText})`:两行渲染——「原:」红删线标被删字、「新:」绿标新增字。
- `FieldChange` 增加 `kind: 'text'|'value'`:价格/图片走 `旧→新`;套餐名称/菜品内容/可用时间/其他说明走 `TextDiff` 逐字高亮。
- `杜康好客-v3.4.17-门店套餐审核快捷入口.md` 新增第 F 节「套餐变更对比·逐字段/逐字明细」,并把原先 v3.4.18 的字段级说明并入,删去独立 v3.4.18 文档计划。
- 提交 `ecd9973`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`
- 结果:构建通过、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `ecd9973`tag=-,无害)。
- 验证要点:开任一「变更」套餐的待审核卡片 → 底部「变更明细」→ 菜品/说明等文本以红删+绿增逐字呈现。
## 优化:h5-partner 门店详情·门店套餐「编辑套餐」按钮
- 需求:按钮**缩小、靠最右侧、保持美观**。根因:`.partner-btn-outline` 是整行大按钮(`flex:1; height:48px; font-size:16px`),放在卡片标题右侧会被拉伸成大块。
- 改动:`apps/h5-partner/src/styles.css` 新增 `.partner-btn-outline--compact``flex:none; height:30px; padding:0 14px; font-size:13px; font-weight:500; border-radius:var(--radius-sm); color:var(--color-primary)`hover 用 `color-mix` 8% 底色);`StoreDetailPage.tsx:288` 的「编辑套餐」按钮加该类(父级 `space-between` 已保证贴右)。
- 提交 `69e201f`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`
- 结果:构建通过、PM2 重启 h5-partner、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `69e201f`tag=-,无害)。
- 注意:`StoreDetailPage.tsx:311` 有个历史遗留 TS 错误(`status === 'CLOSED'` 类型无重叠),与本改动无关,未动。
+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矛盾,疑似另一轮实验性改动。已与用户确认前**暂不提交**。
+27 -147
View File
@@ -1,170 +1,50 @@
# 杜康好客 · 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~~ | 历史参考 | **不再作为需求依据** |
**禁止**:臆造 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:3000/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
同城起购 2 瓶 / 跨城 6 瓶 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟 核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
C 端门店仅 status=OPEN C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成 订单 Tab:待付款 | 已付款 | 已完成
``` ```
## 环境与发版 微信:iOS H5 登录后禁止仅 SPA 跳转再扫码(`packages/weixin-sdk/GOTCHAS.md`)。小程序 `open-type` 按钮祖先禁止 `stopPropagation`weapp 标题只用 `navigationBarTitleText``h5-partner``admin-web` 同端口 5175,勿同时开。
| 环境 | 分支 | 目录 | 端口 | 域名 | ## 按需加载
|------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev` - 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产
- 生产发布 → `deploy/deploy-prod.sh``main`,验收后 - 子 Agent`/owner-a-user-trade` 等(见 `.cursor/agents/`
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example` - DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
- 详情:`@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)
+1 -1
View File
@@ -92,7 +92,7 @@
```text ```text
权益发放额 = common_product_item.benefit_amount ?? price 权益发放额 = common_product_item.benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶 起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额 核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
C 端门店列表仅 status=OPEN C 端门店列表仅 status=OPEN
订单 Taball | pending_pay | pending_ship | pending_receive | completed 订单 Taball | pending_pay | pending_ship | pending_receive | completed
+1
View File
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
- 禁止 import `server/` 或另一个 `apps/*` 的源码 - 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui` - UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed` - C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed`
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
## 新页面 workflow ## 新页面 workflow
+2
View File
@@ -10,7 +10,9 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.5.1", "@ant-design/icons": "^5.5.1",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*", "@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"antd": "^5.22.0", "antd": "^5.22.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"echarts": "^6.1.0", "echarts": "^6.1.0",
+11
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';
@@ -46,6 +47,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage'; import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage'; import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage'; import SystemSettingsPage from './pages/SystemSettingsPage';
import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage'; import WecomBotsPage from './pages/WecomBotsPage';
import WecomMessagePushesPage from './pages/WecomMessagePushesPage'; import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage'; import WecomBotLogsPage from './pages/WecomBotLogsPage';
@@ -64,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>
@@ -136,6 +146,7 @@ export default function App() {
<Route path="/deliveries" element={<DeliveriesPage />} /> <Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} /> <Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} /> <Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} /> <Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} /> <Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route> </Route>
+845 -16
View File
@@ -16,37 +16,163 @@ 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-orders-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: flex-end;
margin-bottom: 12px;
}
.admin-orders-filter-row--tools {
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.admin-orders-filter-item {
margin-bottom: 0 !important;
}
.admin-orders-filter-item--sm {
width: 140px;
}
.admin-orders-filter-item--md {
width: 168px;
}
.admin-orders-filter-item--search {
width: 180px;
}
.admin-users-filter-item--keyword {
width: 240px;
}
.admin-orders-filter-item--status {
width: 220px;
min-width: 180px;
flex: 1 1 180px;
max-width: 280px;
}
.admin-orders-filter-item--date {
flex: 1 1 auto;
}
.admin-orders-filter-actions {
margin-left: auto;
}
.admin-orders-date-picker {
width: 240px;
}
/* 列表页顶栏:标题左、主操作右、列设置最右 */
.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 +197,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,7 +207,711 @@ 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;
} }
.admin-package-audit-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.admin-package-audit-col {
min-width: 0;
}
.admin-package-audit-text {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
max-width: none;
}
/* 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-bgm {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: 8px;
padding: 6px 14px 6px 12px;
border: 1px solid rgba(105, 192, 255, 0.45);
border-radius: 6px;
background: rgba(8, 28, 56, 0.7);
color: rgba(230, 244, 255, 0.92);
font-size: 14px;
font-weight: 700;
letter-spacing: 1px;
cursor: pointer;
line-height: 1;
}
.big-screen-bgm:hover {
border-color: rgba(105, 192, 255, 0.85);
color: #fff;
}
.big-screen-bgm.is-on {
border-color: rgba(64, 169, 255, 0.85);
box-shadow: 0 0 12px rgba(64, 169, 255, 0.28);
color: #fff;
}
.big-screen-bgm-icon {
display: inline-flex;
width: 16px;
height: 16px;
}
.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;
}
}
Binary file not shown.
@@ -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>
);
}
@@ -1,217 +1,383 @@
import { useEffect, useState } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Alert, Button, Form, Input, InputNumber, Space, Typography, message } from 'antd'; import { useNavigate } from 'react-router-dom';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons'; import { DownOutlined, UpOutlined } from '@ant-design/icons';
import type { StorePackageItemDto } from '@dukang/shared-types'; import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types'; import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto; type PackageRow = StorePackageItemDto;
export type AdminStorePackagesHandle = {
/** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */
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 { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index }; return {
name: '',
price: '0',
dishes: '',
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
} }
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) { const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
const [items, setItems] = useState<PackageRow[]>([emptyRow()]); function AdminStorePackagesSection({ storeId }, ref) {
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({}); const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [loading, setLoading] = useState(true); const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
const itemsRef = useRef(items);
const loadingRef = useRef(loading);
const dirtyRef = useRef(false);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
setLoading(true); itemsRef.current = items;
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`) }, [items]);
.then((data) => {
setItems(
data.live?.length
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
: [emptyRow()],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false));
}, [storeId]);
function updateAt(index: number, patch: Partial<PackageRow>) { useEffect(() => {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); loadingRef.current = loading;
} }, [loading]);
function addRow() { function applyServerPackages(data: StorePackagesResponse) {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return; dirtyRef.current = false;
setItems((prev) => [...prev, emptyRow(prev.length)]); setPendingRequest(data.pendingRequest ?? null);
} setItems(mapLiveRows(data.live));
function removeAt(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
setCollapsed((prev) => {
const next: Record<number, boolean> = {};
Object.entries(prev).forEach(([k, v]) => {
const i = Number(k);
if (i < index) next[i] = v;
else if (i > index) next[i - 1] = v;
});
return next;
});
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
async function save() {
const filled = items
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
sortOrder: index,
}))
.filter((item) => item.name || item.dishes || item.price);
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
return;
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
return;
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
}
} }
setSaving(true); useEffect(() => {
try { setLoading(true);
await request(`/admin/stores/${storeId}/packages`, { setPendingRequest(null);
method: 'PUT', dirtyRef.current = false;
body: JSON.stringify({ request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })), .then((data) => applyServerPackages(data))
}), .catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
}); .finally(() => setLoading(false));
message.success('套餐已保存并生效'); }, [storeId]);
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败'); // 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐
} finally { useEffect(() => {
setSaving(false); const onChanged = () => {
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => {
setPendingRequest(data.pendingRequest ?? null);
if (!dirtyRef.current) {
dirtyRef.current = false;
setItems(mapLiveRows(data.live));
}
})
.catch(() => undefined);
};
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
}, [storeId]);
function goAudit() {
if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`);
} }
}
if (loading) { const pendingReminder =
return <Typography.Text type="secondary"></Typography.Text>; pendingRequest && pendingRequest.status === 'PENDING' ? (
} <Alert
type="warning"
return ( showIcon
<Form layout="vertical" requiredMark={false}> style={{ marginBottom: 16 }}
<Alert message="该门店有待审核套餐"
type="info" description={
showIcon <Space>
style={{ marginBottom: 16 }} <Button size="small" type="primary" onClick={goAudit}>
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
/> </Button>
<Button size="small" onClick={goAudit}>
{items.map((item, index) => {
const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`;
return (
<div
key={index}
style={{
marginBottom: 16,
padding: 16,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
<Button
type="text"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => toggleCollapse(index)}
style={{ paddingLeft: 0, height: 'auto' }}
>
<Typography.Title level={5} style={{ margin: 0 }}>
{displayName}
</Typography.Title>
</Button> </Button>
{items.length > 1 ? (
<Button type="link" danger onClick={() => removeAt(index)}>
</Button>
) : null}
</Space> </Space>
}
/>
) : null;
{!isCollapsed ? ( function updateAt(index: number, patch: Partial<PackageRow>) {
<> dirtyRef.current = true;
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}> setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
<Input }
placeholder="如:套餐A"
value={item.name}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</Form.Item>
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}> function addRow() {
<InputNumber if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
min={0} dirtyRef.current = true;
precision={2} setItems((prev) => [...prev, emptyRow(prev.length)]);
style={{ width: '100%' }} }
addonAfter="元"
placeholder="198"
value={item.price === '' ? undefined : Number(item.price)}
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
/>
</Form.Item>
<Form.Item label="菜品" required style={{ marginBottom: 12 }}> function removeAt(index: number) {
<Input.TextArea const run = () => {
rows={2} dirtyRef.current = true;
placeholder="红烧肉、红烧鱼、油焖茄子" setItems((prev) => {
value={item.dishes} const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
onChange={(e) => updateAt(index, { dishes: e.target.value })} return next.length ? next : [];
/> });
</Form.Item> setCollapsed((prev) => {
const next: Record<number, boolean> = {};
Object.entries(prev).forEach(([k, v]) => {
const i = Number(k);
if (i < index) next[i] = v;
else if (i > index) next[i - 1] = v;
});
return next;
});
};
if (items.length === 1) {
Modal.confirm({
title: '清空门店套餐',
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
okText: '确认删除',
cancelText: '取消',
onOk: run,
});
return;
}
run();
}
<Form.Item label="使用时间" style={{ marginBottom: 12 }}> function toggleCollapse(index: number) {
<Input setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
placeholder="节假日除外" }
value={item.usableTime || ''}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</Form.Item>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}> async function save(opts?: { quiet?: boolean }) {
<Input const currentItems = itemsRef.current;
placeholder="不可叠加" const filled = currentItems
value={item.otherNotes || ''} .map((item, index) => {
onChange={(e) => updateAt(index, { otherNotes: e.target.value })} const imageUrls = normalizeStorePackageImageUrls(item);
/> return {
</Form.Item> name: item.name.trim(),
</> price: item.price.trim(),
) : null} dishes: item.dishes.trim(),
</div> usableTime: item.usableTime?.trim() || null,
); otherNotes: item.otherNotes?.trim() || null,
})} imageUrl: imageUrls[0] ?? null,
imageUrls,
sortOrder: index,
};
})
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
.filter((item) => {
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
const hasImages = item.imageUrls.length > 0;
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
return hasText || hasImages || hasNonZeroPrice;
});
{items.length < STORE_PACKAGE_MAX_COUNT ? ( for (let i = 0; i < filled.length; i++) {
<Button onClick={addRow} style={{ marginBottom: 16 }}> const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
throw new Error('套餐校验失败');
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
throw new Error('套餐校验失败');
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
throw new Error('套餐校验失败');
}
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`);
throw new Error('套餐校验失败');
}
}
setSaving(true);
try {
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
method: 'PUT',
body: JSON.stringify({
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
if (!opts?.quiet) message.success('套餐已保存并生效');
dirtyRef.current = false;
setItems(mapLiveRows(data.live));
} catch (e) {
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
throw e;
} finally {
setSaving(false);
}
}
useImperativeHandle(ref, () => ({
saveIfLoaded: async (opts) => {
if (loadingRef.current || !dirtyRef.current) return { skipped: true };
await save(opts);
return { skipped: false };
},
}));
if (loading) {
return <Typography.Text type="secondary"></Typography.Text>;
}
if (!items.length) {
return (
<Form layout="vertical" requiredMark={false}>
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
{pendingReminder}
<Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button>
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button>
</Form>
);
}
return (
<Form layout="vertical" requiredMark={false}>
{pendingReminder}
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
/>
{items.map((item, index) => {
const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`;
return (
<div
key={index}
style={{
marginBottom: 16,
padding: 16,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
<Button
type="text"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => toggleCollapse(index)}
style={{ paddingLeft: 0, height: 'auto' }}
>
<Typography.Title level={5} style={{ margin: 0 }}>
{displayName}
</Typography.Title>
</Button>
{items.length > 0 ? (
<Button type="link" danger onClick={() => removeAt(index)}>
</Button>
) : null}
</Space>
{!isCollapsed ? (
<>
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
<Input
placeholder="如:套餐A"
value={item.name}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</Form.Item>
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonAfter="元"
placeholder="198"
value={item.price === '' ? undefined : Number(item.price)}
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
/>
</Form.Item>
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
<Input.TextArea
rows={2}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
/>
</Form.Item>
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
<Input.TextArea
rows={2}
placeholder="节假日除外"
value={item.usableTime || ''}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</Form.Item>
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
<PackageImagesUpload
value={normalizeStorePackageImageUrls(item)}
onChange={(imageUrls) =>
updateAt(index, {
imageUrls,
imageUrl: imageUrls[0] ?? '',
})
}
/>
</Form.Item>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
<Input.TextArea
rows={2}
placeholder="不可叠加"
value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</Form.Item>
</>
) : null}
</div>
);
})}
{items.length < STORE_PACKAGE_MAX_COUNT ? (
<Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button>
) : null}
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
</Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button> </Button>
) : null} </Form>
);
},
);
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}> export default AdminStorePackagesSection;
C
</Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
</Form>
);
}
@@ -26,10 +26,12 @@ import {
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 { omitNullFields } from '../lib/omit-null-fields';
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';
import PartnerAssocPanel from './PartnerAssocPanel';
type PartnerRow = { type PartnerRow = {
id: string; id: string;
@@ -186,7 +188,7 @@ export default function CityPartnersPanel({
await request(`/admin/partners/${detail.id}`, { await request(`/admin/partners/${detail.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
...v, ...omitNullFields(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,
@@ -215,11 +217,10 @@ export default function CityPartnersPanel({
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: '管辖',
@@ -346,6 +347,11 @@ export default function CityPartnersPanel({
/> />
), ),
}, },
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]} ]}
/> />
)} )}
@@ -1,6 +1,5 @@
import { Button, Form, Space, Typography } from 'antd'; import { Typography } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import MultiImageUpload from './MultiImageUpload';
import OssUpload from './OssUpload';
type Props = { type Props = {
name?: string; name?: string;
@@ -8,42 +7,35 @@ type Props = {
bizType?: string; bizType?: string;
/** 最多可添加张数;不传则不限制 */ /** 最多可添加张数;不传则不限制 */
maxCount?: number; maxCount?: number;
/** Form.Item 注入 */
value?: string[];
onChange?: (urls: string[]) => void;
}; };
/**
* 商品详情/轮播等多图列表。
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
*/
export default function DetailImageUrlList({ export default function DetailImageUrlList({
name = 'detailImageUrls',
label, label,
bizType = 'DETAIL', bizType = 'DETAIL',
maxCount, maxCount,
value,
onChange,
}: Props) { }: Props) {
return ( return (
<> <>
{maxCount != null && ( <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> {label}{maxCount != null ? `,最多 ${maxCount}` : ''}
{maxCount} {label} </Typography.Text>
</Typography.Text> <MultiImageUpload
)} bizType={bizType}
<Form.List name={name}> mediaType="IMAGE"
{(fields, { add, remove }) => ( maxCount={maxCount}
<> value={value}
{fields.map((field) => ( onChange={onChange}
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}> tip={`支持一次选择多张${label}`}
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}> />
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
{(!maxCount || fields.length < maxCount) && (
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
)}
</>
)}
</Form.List>
</> </>
); );
} }
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { Image, Input, Modal, Space, Typography } from 'antd';
import { PAYMENT_PROOF_IMAGE_MAX_COUNT } from '@dukang/shared-types';
import MultiImageUpload from './MultiImageUpload';
export function parsePaymentProofUrls(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw.map((u) => String(u ?? '').trim()).filter(Boolean);
}
export function PaymentProofGallery({ urls }: { urls?: unknown }) {
const list = parsePaymentProofUrls(urls);
if (!list.length) return <></>;
return (
<Image.PreviewGroup>
<Space wrap size={8}>
{list.map((url, index) => (
<Image
key={`${url}-${index}`}
src={url}
width={72}
height={72}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
))}
</Space>
</Image.PreviewGroup>
);
}
type FinancePayProofModalProps = {
open: boolean;
title: string;
hint: string;
okText: string;
confirmLoading?: boolean;
onCancel: () => void;
onOk: (payload: { paymentRef?: string; paymentProofUrls?: string[] }) => Promise<void>;
};
export function FinancePayProofModal({
open,
title,
hint,
okText,
confirmLoading,
onCancel,
onOk,
}: FinancePayProofModalProps) {
const [paymentRef, setPaymentRef] = useState('');
const [proofUrls, setProofUrls] = useState<string[]>([]);
useEffect(() => {
if (open) {
setPaymentRef('');
setProofUrls([]);
}
}, [open]);
return (
<Modal
title={title}
open={open}
okText={okText}
cancelText="取消"
confirmLoading={confirmLoading}
destroyOnClose
width={480}
onCancel={onCancel}
onOk={async () => {
await onOk({
paymentRef: paymentRef.trim() || undefined,
paymentProofUrls: proofUrls.length ? proofUrls : undefined,
});
}}
>
<Typography.Paragraph style={{ marginBottom: 12 }}>{hint}</Typography.Paragraph>
<Input
placeholder="打款凭证号(可选)"
value={paymentRef}
onChange={(e) => setPaymentRef(e.target.value)}
style={{ marginBottom: 12 }}
/>
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginTop: 8 }}>
<MultiImageUpload
bizType="PAYMENT_PROOF"
value={proofUrls}
onChange={setProofUrls}
maxCount={PAYMENT_PROOF_IMAGE_MAX_COUNT}
buttonText="上传凭证照片"
tip={`最多 ${PAYMENT_PROOF_IMAGE_MAX_COUNT} 张,支持一次选择多张`}
/>
</div>
</Modal>
);
}
@@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Image, Space, Typography, Upload, message } from 'antd';
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
bizType: string;
mediaType?: OssMediaType;
/** 最多张数;不传则不限制 */
maxCount?: number;
tip?: string;
accept?: string;
/** 上传按钮文案,默认「批量上传图片」 */
buttonText?: string;
};
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
function isPdf(url: string) {
return /\.pdf(\?|$)/i.test(url);
}
/**
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
* Form.Item 直接绑定 string[]。
*/
export default function MultiImageUpload({
value,
onChange,
bizType,
mediaType = 'IMAGE',
maxCount,
tip,
accept = 'image/*',
buttonText = '批量上传图片',
}: Props) {
const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
const onChangeRef = useRef(onChange);
const [uploading, setUploading] = useState(false);
const batchBuf = useRef<File[]>([]);
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const uploadChain = useRef(Promise.resolve());
useEffect(() => {
urlsRef.current = urls;
}, [urls]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
return () => {
if (batchTimer.current) clearTimeout(batchTimer.current);
};
}, []);
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
const canAdd = remaining > 0;
async function uploadBatch(files: File[]) {
const current = urlsRef.current;
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
const picked = files.slice(0, room);
if (!picked.length) {
message.warning(maxCount != null ? `最多 ${maxCount}` : '无法上传');
return;
}
if (files.length > picked.length) {
message.warning(`已达上限,仅上传前 ${picked.length}`);
}
setUploading(true);
const appended: string[] = [];
let fail = 0;
try {
for (const file of picked) {
try {
const result = await uploadFileToOss(file, { bizType, mediaType });
appended.push(result.url);
} catch {
fail += 1;
}
}
if (appended.length) {
// 始终基于最新列表追加,避免并行上传互相覆盖
const next = [...urlsRef.current, ...appended];
urlsRef.current = next;
onChangeRef.current?.(next);
message.success(`成功上传 ${appended.length}${fail ? `,失败 ${fail}` : ''}`);
} else if (fail) {
message.error('上传失败');
}
} finally {
setUploading(false);
}
}
function enqueueUploadBatch(files: File[]) {
uploadChain.current = uploadChain.current
.then(() => uploadBatch(files))
.catch(() => undefined);
}
function flushBatch() {
if (batchTimer.current) {
clearTimeout(batchTimer.current);
batchTimer.current = null;
}
if (!batchBuf.current.length) return;
const files = [...batchBuf.current];
batchBuf.current = [];
enqueueUploadBatch(files);
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
batchBuf.current.push(file as File);
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
if (batchTimer.current) clearTimeout(batchTimer.current);
batchTimer.current = setTimeout(() => {
flushBatch();
}, 80);
return false;
};
function removeAt(index: number) {
const next = urlsRef.current.filter((_, i) => i !== index);
urlsRef.current = next;
onChangeRef.current?.(next);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{tip ??
(maxCount != null
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
: '支持一次选择多张批量上传')}
{maxCount != null ? `(已选 ${urls.length}/${maxCount}` : urls.length ? `(已选 ${urls.length}` : ''}
</Typography.Text>
{urls.length > 0 ? (
<Image.PreviewGroup>
<Space wrap size={12}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: 96,
height: 96,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 4,
borderRadius: 6,
border: '1px solid #f0f0f0',
background: '#fafafa',
fontSize: 12,
}}
>
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
<span>PDF</span>
</a>
) : (
<Image
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
)}
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeAt(index)}
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'rgba(255,255,255,0.85)',
}}
/>
</div>
))}
</Space>
</Image.PreviewGroup>
) : null}
<Upload
accept={accept}
multiple
showUploadList={false}
beforeUpload={beforeUpload}
disabled={uploading || !canAdd}
>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
{canAdd ? buttonText : '已达上限'}
</Button>
</Upload>
</Space>
);
}
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Empty,
Image,
Space,
Spin,
Timeline,
Typography,
message,
} from 'antd';
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
import { isXfxProviderCode } from '@dukang/shared-types';
import { request } from '../lib/api';
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
type Props = {
open: boolean;
orderId: string | null;
orderNo?: string | null;
orderStatus?: string | null;
onClose: () => void;
};
function providerLabel(provider?: string | null, company?: string | null) {
if (company) return company;
if (!provider) return '—';
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
if (isXfxProviderCode(provider)) return '小飞侠';
return provider;
}
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
return [...nodes].sort((a, b) => {
const ta = new Date(a.createTime).getTime();
const tb = new Date(b.createTime).getTime();
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
});
}
export default function OrderTrackDrawer({
open,
orderId,
orderNo,
orderStatus,
onClose,
}: Props) {
const [loading, setLoading] = useState(false);
const [track, setTrack] = useState<OrderTrackDto | null>(null);
const load = useCallback(async () => {
if (!orderId) return;
setLoading(true);
try {
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
setTrack(data);
} catch (e) {
setTrack(null);
message.error(e instanceof Error ? e.message : '加载路由失败');
} finally {
setLoading(false);
}
}, [orderId]);
useEffect(() => {
if (!open || !orderId) {
setTrack(null);
return;
}
void load();
}, [open, orderId, load]);
const nodes = useMemo(
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
[track?.nodes],
);
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
orderStatus || '',
);
return (
<Drawer
title="物流路由"
width={520}
open={open}
onClose={onClose}
extra={
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
</Button>
}
>
<Spin spinning={loading}>
{(orderNo || track?.trackingNo || track?.provider) && (
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
<Descriptions.Item label="配送方式">
{providerLabel(track?.provider, track?.logisticsCompany)}
</Descriptions.Item>
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
{track?.manualQueryUrl ? (
<Descriptions.Item label="物流查询">
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
</a>
</Descriptions.Item>
) : null}
{track?.estimatedArrival?.arriveTime ? (
<Descriptions.Item label="预计送达">
{fmtTime(track.estimatedArrival.arriveTime)}
{track.estimatedArrival.siteName
? `${track.estimatedArrival.siteName}`
: ''}
</Descriptions.Item>
) : null}
</Descriptions>
)}
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
<div style={{ marginBottom: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<Image.PreviewGroup>
<Space wrap>
{track.signPhotoUrls.map((url) => (
<Image
key={url}
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
))}
</Space>
</Image.PreviewGroup>
</div>
) : null}
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{nodes.length > 0 ? (
<Timeline
items={nodes.map((node, index) => {
const isLatest = index === nodes.length - 1;
return {
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
children: (
<div>
{node.statusName ? (
<Typography.Text strong style={{ display: 'block' }}>
{node.statusName}
</Typography.Text>
) : null}
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
{node.trackInfo || '—'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(node.createTime)}
</Typography.Text>
</div>
),
};
})}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
track?.queryError
? track.queryError
: track?.manualQueryUrl
? '暂无实时路由节点,可使用上方物流查询链接'
: '暂无路由信息,请稍后刷新'
}
/>
)}
</Spin>
</Drawer>
);
}
+62 -10
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd'; import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons'; import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd'; import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload'; import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
@@ -15,7 +15,13 @@ type OssUploadProps = {
placeholder?: string; placeholder?: string;
}; };
const DEFAULT_MAX_MB = 10; function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
export default function OssUpload({ export default function OssUpload({
value, value,
@@ -24,22 +30,16 @@ export default function OssUpload({
bizType, bizType,
mediaType = 'IMAGE', mediaType = 'IMAGE',
accept, accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL', placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) { }: OssUploadProps) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
const resolvedAccept = const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*'); accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File; const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true); setUploading(true);
try { try {
const result = await uploadFileToOss(raw, { bizType, mediaType }); const result = await uploadFileToOss(raw, { bizType, mediaType });
@@ -56,6 +56,57 @@ export default function OssUpload({
} }
}; };
const filePreview =
value && mediaType === 'FILE' ? (
isImageUrl(value) ? (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : isPdfUrl(value) ? (
<Space direction="vertical" size={8}>
<div
style={{
width: 120,
height: 120,
borderRadius: 4,
border: '1px solid #f0f0f0',
background: '#fafafa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 36 }} />
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF </span>
</div>
<Space wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
</Button>
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
</Button>
</Space>
<Modal
title="签约合同预览"
open={pdfPreviewOpen}
onCancel={() => setPdfPreviewOpen(false)}
footer={null}
width="90vw"
styles={{ body: { height: '75vh', padding: 0 } }}
destroyOnClose
>
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
</Modal>
</Space>
) : (
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
</Button>
)
) : null;
return ( return (
<Space direction="vertical" style={{ width: '100%' }} size="small"> <Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && ( {value && mediaType === 'IMAGE' && (
@@ -64,6 +115,7 @@ export default function OssUpload({
{value && mediaType === 'VIDEO' && ( {value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} /> <video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)} )}
{filePreview}
<Space wrap> <Space wrap>
<Upload <Upload
accept={resolvedAccept} accept={resolvedAccept}
@@ -0,0 +1,21 @@
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
import MultiImageUpload from './MultiImageUpload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
};
/** 套餐多图:批量上传,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
export default function PackageImagesUpload({ value, onChange }: Props) {
return (
<MultiImageUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
value={value}
onChange={onChange}
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
/>
);
}
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
import { request, type HqProfile, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
function formatNicknameWithRemark(row: PartnerAssocUserItem) {
const name = row.nickname?.trim() || '—';
const remark = row.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
}
type PartnerAssocPanelProps = {
partnerId: string;
};
export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) {
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
const [users, setUsers] = useState<PartnerAssocUserItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [issuing, setIssuing] = useState(false);
const [canEditAssoc, setCanEditAssoc] = useState(false);
const loadSummary = useCallback(async () => {
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
setSummary(data);
}, [partnerId]);
const loadUsers = useCallback(async (p = 1) => {
setLoading(true);
try {
const res = await request<Paginated<PartnerAssocUserItem>>(
`/admin/partners/${partnerId}/assoc/users?page=${p}&pageSize=20`,
);
setUsers(res.items ?? []);
setTotal(res.total ?? 0);
setPage(p);
} finally {
setLoading(false);
}
}, [partnerId]);
useEffect(() => {
void request<HqProfile>('/admin/auth/me')
.then((p) => setCanEditAssoc((p.permissionKeys ?? []).includes('users_partner_assoc')))
.catch(() => setCanEditAssoc(false));
}, []);
useEffect(() => {
void loadSummary().catch((e) => message.error(e instanceof Error ? e.message : '加载关联码失败'));
void loadUsers(1).catch(() => undefined);
}, [loadSummary, loadUsers]);
async function reissue() {
setIssuing(true);
try {
await request(`/admin/partners/${partnerId}/assoc/qrcode`, { method: 'POST' });
message.success('已重新生成关联码');
await loadSummary();
} catch (e) {
message.error(e instanceof Error ? e.message : '生成失败');
} finally {
setIssuing(false);
}
}
async function unbind(userId: string) {
try {
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
message.success('已解绑');
await Promise.all([loadSummary(), loadUsers(page)]);
} catch (e) {
message.error(e instanceof Error ? e.message : '解绑失败');
}
}
const columns: ColumnsType<PartnerAssocUserItem> = [
{ title: '用户编号', dataIndex: 'userNo', width: 140 },
{
title: '用户',
dataIndex: 'nickname',
render: (_, row) => (
<Link
className="admin-primary-link"
to={`/users?userId=${encodeURIComponent(row.id)}`}
title="在用户列表中查看"
>
{formatNicknameWithRemark(row)}
</Link>
),
},
{ title: '手机', dataIndex: 'phone', render: (v) => v || '—' },
{ title: '关联时间', dataIndex: 'boundAt', render: (v) => (v ? fmtTime(v) : '—') },
{ title: '已付订单', dataIndex: 'orderCount', width: 90 },
...(canEditAssoc
? [
{
title: '操作',
width: 90,
render: (_: unknown, row: PartnerAssocUserItem) => (
<Popconfirm title="解绑后该用户新单不再计订单佣金" onConfirm={() => void unbind(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
} satisfies ColumnsType<PartnerAssocUserItem>[number],
]
: []),
];
return (
<div>
<Space align="start" style={{ marginBottom: 16 }} wrap>
{summary?.qrcodeUrl ? (
<img src={summary.qrcodeUrl} alt="关联码" style={{ width: 160, height: 160, background: '#fff' }} />
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
<div>
<Typography.Paragraph style={{ marginBottom: 8 }}>
<Link to={`/users?assocPartnerAccountId=${encodeURIComponent(partnerId)}`}>
{summary?.userCount ?? 0}
</Link>
</Typography.Paragraph>
<Button loading={issuing} onClick={() => void reissue()}>
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
</Button>
</div>
</Space>
<Table
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={users}
pagination={{
current: page,
pageSize: 20,
total,
onChange: (p) => void loadUsers(p),
}}
/>
</div>
);
}
@@ -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,623 @@
import { useEffect, 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('_');
}
function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] {
if (!initialSkus.length) {
return [
{
specValueIds: [],
barcode69: '',
price: 0,
status: 'ON_SALE',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
imageUrl: '',
},
];
}
return 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 ?? '',
}));
}
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[]>(() => normalizeSkuRows(initialSkus));
const [saving, setSaving] = useState(false);
const [editIndex, setEditIndex] = useState<number | null>(null);
useEffect(() => {
setAttrs(initialAttrs.length ? initialAttrs : []);
setSkus(normalizeSkuRows(initialSkus));
setEditIndex(null);
// 仅切商品时重载,避免父组件重渲染冲掉编辑中内容
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId]);
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,8 +51,10 @@ 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 [assocPartnerAccountId, setAssocPartnerAccountId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS'); const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false); const [autoReceive, setAutoReceive] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null); const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
@@ -87,9 +89,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 +145,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 +155,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,7 +166,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setRegionCodes([]); setRegionCodes([]);
setAddressDetail(''); setAddressDetail('');
setQuantity(2); setQuantity(2);
setProductId(undefined);
setSkuId(undefined);
setPromoCodeId(undefined); setPromoCodeId(undefined);
setAssocPartnerAccountId(undefined);
setDeliveryMode('ADDRESS'); setDeliveryMode('ADDRESS');
setAutoReceive(false); setAutoReceive(false);
setPreview(null); setPreview(null);
@@ -202,6 +236,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId: productId!, productId: productId!,
quantity, quantity,
promoCodeId: promoCodeId || undefined, promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
assocPartnerAccountId: assocPartnerAccountId || undefined,
}; };
setSubmitting(true); setSubmitting(true);
@@ -217,6 +253,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 +353,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 +364,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}
@@ -328,6 +391,21 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
/> />
</Form.Item> </Form.Item>
<Form.Item label="关联合伙人(选填)">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="不选则本单不计订单佣金"
value={assocPartnerAccountId}
onChange={setAssocPartnerAccountId}
options={(options?.partners ?? []).map((p) => ({
value: p.id,
label: `${p.companyName || p.name}${p.phone ? ` · ${p.phone}` : ''}`,
}))}
/>
</Form.Item>
{(options?.promoCodes.length ?? 0) > 0 ? ( {(options?.promoCodes.length ?? 0) > 0 ? (
<Form.Item label="绑定推广码(选填)"> <Form.Item label="绑定推广码(选填)">
<Select <Select
@@ -0,0 +1,113 @@
import { Descriptions } from 'antd';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? '—';
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
type Props = {
detail: Record<string, unknown>;
/** 列表页已脱敏展示时可设为 false,详情抽屉展示完整手机号 */
maskUserPhone?: boolean;
};
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
const user = detail.user as
| {
id?: string;
userNo?: string;
nickname?: string | null;
phone?: string | null;
hqRemark?: string | null;
}
| undefined;
const store = detail.store as
| {
name?: string;
cityName?: string;
address?: string;
partnerAccount?: { companyName?: string };
primaryAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
}
| undefined;
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
const channel = detail.channel === 'PHONE' ? 'PHONE' : 'SCAN';
return (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(detail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="方式">{REDEEM_CHANNEL_LABELS[channel as RedeemChannel]}</Descriptions.Item>
<Descriptions.Item label="核销额">¥{Number(detail.amount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户昵称">
{(() => {
const name = user?.nickname?.trim() || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
})()}
</Descriptions.Item>
<Descriptions.Item label="用户手机">
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
</Descriptions.Item>
<Descriptions.Item label="门店">
{store?.name ?? '—'}
{store?.cityName ? `${store.cityName}` : ''}
</Descriptions.Item>
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="收款户名">
{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?.order?.orderNo ?? '—'}</Descriptions.Item>
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="券分摊">
{(
detail.allocations as Array<{
couponNo?: string;
orderNo?: string | null;
amount?: number;
}>
)
.map((a, idx) => {
const role = idx === 0 ? '主' : '次';
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
a.orderNo ? `(订单 ${a.orderNo}` : ''
}`;
})
.join('')}
</Descriptions.Item>
) : null}
{detail.payout ? (
<Descriptions.Item label="门店结算单">
¥{Number((detail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
{' / '}
{String((detail.payout as { status?: string }).status ?? '—')}
</Descriptions.Item>
) : null}
{detail.rating ? (
<Descriptions.Item label="评价">
{(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '}
{(detail.rating as { envScore?: number }).envScore ?? '—'}
{(detail.rating as { comment?: string | null }).comment
? ` · ${(detail.rating as { comment?: string }).comment}`
: ''}
</Descriptions.Item>
) : null}
</Descriptions>
);
}
@@ -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();
}
+113 -43
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd'; import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { import {
RobotOutlined, RobotOutlined,
@@ -20,18 +20,27 @@ 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 { 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'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' }, { key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' }, { key: '/users', icon: <UserOutlined />, label: '用户' },
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
{ {
key: 'products-group', key: 'products-group',
icon: <ShoppingOutlined />, icon: <ShoppingOutlined />,
@@ -42,31 +51,6 @@ const MENU_ITEMS: MenuProps['items'] = [
], ],
}, },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' }, { key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{
key: 'wecom-group',
icon: <TeamOutlined />,
label: '企微机器人',
children: [
{ key: '/wecom/bots', label: '智能机器人' },
{ key: '/wecom/pushes', label: '消息推送' },
],
},
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
{
key: 'stores-group',
icon: <ShopOutlined />,
label: '门店',
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-package-audits', label: '套餐审核' },
{ key: '/store-ratings', label: '门店评价' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' },
],
},
{ {
key: 'partners-group', key: 'partners-group',
icon: <TeamOutlined />, icon: <TeamOutlined />,
@@ -78,6 +62,19 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/fulfillment-providers', label: '仓配管理' }, { key: '/fulfillment-providers', label: '仓配管理' },
], ],
}, },
{
key: 'stores-group',
icon: <ShopOutlined />,
label: '门店',
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-package-audits', label: '审核通知' },
{ key: '/store-ratings', label: '门店评价' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' },
],
},
{ {
key: 'finance-group', key: 'finance-group',
icon: <AccountBookOutlined />, icon: <AccountBookOutlined />,
@@ -119,6 +116,20 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/tickets/support', label: '技术支持' }, { key: '/tickets/support', label: '技术支持' },
], ],
}, },
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
{
key: 'wecom-group',
icon: <TeamOutlined />,
label: '企微机器人',
children: [
{ key: '/wecom/bots', label: '智能机器人' },
{ key: '/wecom/pushes', label: '消息推送' },
],
},
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
{ {
key: 'dev-plan-group', key: 'dev-plan-group',
icon: <ProjectOutlined />, icon: <ProjectOutlined />,
@@ -129,7 +140,6 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/dev-plan/settings', label: '开发设置' }, { key: '/dev-plan/settings', label: '开发设置' },
], ],
}, },
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' }, { key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{ {
key: 'logs-group', key: 'logs-group',
@@ -146,14 +156,18 @@ const MENU_ITEMS: MenuProps['items'] = [
], ],
}, },
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' }, { key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' }, { key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
]; ];
function menuAllowed(key: string, permissionKeys: string[]): boolean { 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');
} }
@@ -173,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',
@@ -194,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',
@@ -215,6 +229,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/third-party': 'logs', '/logs/third-party': 'logs',
'/logs/domain-events': 'logs', '/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions', '/hq-permissions': 'hq_permissions',
'/test-whitelist': 'test_whitelist',
'/system-settings': 'system_settings_any', '/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts', '/hq-accounts': 'hq_accounts',
}; };
@@ -241,6 +256,31 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
.filter(Boolean) as MenuProps['items']; .filter(Boolean) as MenuProps['items'];
} }
function attachAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
if (!items) return items;
return items.map((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) return item;
if ('children' in item && Array.isArray(item.children)) {
return {
...item,
children: attachAuditBadge(item.children as MenuProps['items'], pendingCount),
} as MenuItem;
}
if (String(item.key) === '/store-package-audits') {
return {
...item,
label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
</span>
),
} as MenuItem;
}
return item;
});
}
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging'; const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
export default function AdminLayout() { export default function AdminLayout() {
@@ -248,11 +288,35 @@ 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 [auditPendingCount, setAuditPendingCount] = useState(0);
function refreshAuditPendingCount() {
Promise.all([
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(() => {});
}
useEffect(() => { useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
useEffect(() => {
refreshAuditPendingCount();
}, [location.pathname]);
useEffect(() => {
const onChanged = () => refreshAuditPendingCount();
window.addEventListener(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(() => {
contentRef.current?.scrollTo({ top: 0, left: 0 }); contentRef.current?.scrollTo({ top: 0, left: 0 });
}, [location.pathname]); }, [location.pathname]);
@@ -271,10 +335,12 @@ export default function AdminLayout() {
: location.pathname; : location.pathname;
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
if (!profile) return MENU_ITEMS; const base =
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS; !profile || profile.adminRole === 'SUPER_ADMIN'
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []); ? MENU_ITEMS
}, [profile]); : filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
return attachAuditBadge(base, auditPendingCount);
}, [profile, auditPendingCount]);
return ( return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}> <Layout style={{ height: '100vh', overflow: 'hidden' }}>
@@ -347,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>
@@ -359,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);
}
+11
View File
@@ -0,0 +1,11 @@
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed';
export function notifyPackageAuditChanged() {
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
window.dispatchEvent(new Event(AUDIT_NOTICE_CHANGED_EVENT));
}
export function notifyAuditNoticeChanged() {
notifyPackageAuditChanged();
}
+39 -1
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,12 +169,26 @@ 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;
sourceLabel: string | null; sourceLabel: string | null;
createdAt: string; createdAt: string;
orderCount: number; orderCount: number;
isTest?: boolean;
/** 好客权益·累计获得(含已使用,不含退款作废) */
benefitTotalAmount?: number;
/** 好客权益·已使用(已核销) */
benefitUsedAmount?: number;
/** 好客权益·剩余未使用 */
benefitBalance?: number;
assocPartner?: {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
} | null;
}; };
export type AdminOrderItem = { export type AdminOrderItem = {
@@ -186,8 +208,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 };
@@ -199,12 +234,15 @@ export type AdminOrderRow = {
isProxyOrder?: boolean; isProxyOrder?: boolean;
proxyPartnerName?: string | null; proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null; proxyPartnerPhone?: string | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; isTest?: boolean;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null };
delivery?: { delivery?: {
provider: string; provider: string;
trackingNo: string | null; trackingNo: string | null;
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}
</>
);
}
+36 -2
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: '待发货',
@@ -25,9 +31,18 @@ export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
USER_ON_SITE: '用户·现场取货', USER_ON_SITE: '用户·现场取货',
USER_WECHAT_CONFIRM: '用户·微信确认收货', USER_WECHAT_CONFIRM: '用户·微信确认收货',
WECHAT_TRADE_MANAGE: '微信结算同步', WECHAT_TRADE_MANAGE: '微信结算同步',
WECHAT_PAY: '微信支付',
MOCK: 'Mock', MOCK: 'Mock',
MOCK_PAY: 'Mock 支付', MOCK_PAY: 'Mock 支付',
SYSTEM: '系统', SYSTEM: '系统',
SYSTEM_AUTO_EXPIRE: '系统·超时取消',
PARTNER_PROXY: '合伙人代下单',
HQ_PROXY: 'HQ 代操作',
HQ_LOGISTICS: 'HQ 物流',
WAREHOUSE_MANUAL: '仓库手工',
HQ: '总部',
STORE: '门店',
PARTNER: '合伙人',
}; };
/** 订单状态 Tag 颜色(Ant Design preset */ /** 订单状态 Tag 颜色(Ant Design preset */
@@ -140,10 +155,29 @@ export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—'; return v ? new Date(v).toLocaleString('zh-CN') : '—';
} }
/** 手机号脱敏展示:138****5678;空值返回 — */ /** 联系电话脱敏:手机 138****5678;座机 0379-****888;空值返回 — */
export function maskPhone(phone?: string | null): string { export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim(); const raw = String(phone ?? '').trim().replace(/\s+/g, '');
if (!raw) return '—'; if (!raw) return '—';
if (/^1[3-9]\d{9}$/.test(raw)) {
const digits = raw.replace(/\D/g, '');
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
const extMatch = raw.match(/-(\d{1,6})$/);
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
const ext = hasExt ? extMatch![1] : '';
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
const digits = main.replace(/\D/g, '');
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
const area = digits.slice(0, areaLen);
const local = digits.slice(areaLen);
const keepTail = Math.min(4, Math.max(2, local.length - 4));
const maskedLocal =
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
const joiner = main.includes('-') ? '-' : '';
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
}
const digits = raw.replace(/\D/g, ''); const digits = raw.replace(/\D/g, '');
if (digits.length >= 11) { if (digits.length >= 11) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`; return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
+358
View File
@@ -0,0 +1,358 @@
import {
LEDGER_TYPE_LABELS,
ORDER_STATUS_LABELS,
ORDER_STATUS_OPERATOR_LABELS,
} from './constants';
import { HQ_OPERATION_ACTION_LABELS } from './hq-log';
import { USER_LOG_CATEGORY_LABELS } from './user-log';
import {
PARTNER_LOG_CATEGORY_LABELS,
HQ_ADMIN_ROLES,
resolvePartnerLogCategory,
resolveStoreLogCategory,
resolveUserLogCategory,
STORE_LOG_CATEGORY_LABELS,
WECOM_BOT_PERMISSION_LABELS,
} from '@dukang/shared-types';
export function zhLabel(dict: Record<string, string>, value?: string | null, empty = '—'): string {
if (value == null || value === '') return empty;
return dict[value] ?? value;
}
export const CLIENT_APP_LABELS: Record<string, string> = {
USER_MINI: 'C 端小程序',
USER_H5: 'C 端 H5',
PARTNER_MINI: '合伙人小程序',
PARTNER_H5: '合伙人 H5',
HQ_MINI: 'HQ 小程序',
HQ_WEB: 'HQ 后台',
SHOP_H5: '门店 H5',
};
export const REF_TYPE_LABELS: Record<string, string> = {
ORDER: '订单',
USER: '用户',
STORE: '门店',
PARTNER: '合伙人',
CITY: '城市',
PRODUCT: '商品',
COUPON: '权益券',
TICKET: '工单',
SUPPORT_TICKET: '技术支持工单',
INVOICE: '发票',
PROMO: '推广码',
PROMO_CODE: '推广码',
HQ: '总部',
HQ_ACCOUNT: 'HQ 账号',
STORE_ACCOUNT: '门店账号',
PARTNER_ACCOUNT: '合伙人账号',
PAYOUT: '打款',
WITHDRAWAL: '提现',
BILL: '账单',
DELIVERY: '配送单',
REDEEM: '核销',
WAREHOUSE: '仓库',
PACKAGE: '套餐',
};
export const DOMAIN_EVENT_TYPE_LABELS: Record<string, string> = {
ORDER_STATUS: '订单状态',
BENEFIT_LEDGER: '权益流水',
STORE_AUDIT: '门店审核',
TICKET_COLLAB: '工单协作',
PROMO_TOUCH: '推广触达',
HQ_OPERATION: 'HQ 操作',
};
export const PARAM_DESC_LABELS: Record<string, string> = {
from_status: '原状态',
to_status: '目标状态',
operator: '操作人',
ledger_type: '流水类型',
coupon_id: '权益券 ID',
action: '行为',
target_type: '对象类型',
target_id: '对象 ID',
};
export const USER_LOG_EVENT_LABELS: Record<string, string> = {
login_success: '登录成功',
sms_login: '短信登录',
sms_send: '发送验证码',
sms_verify_fail: '验证码校验失败',
session_start: '会话开始',
home_view: '浏览首页',
product_list_view: '浏览商品列表',
product_click: '点击商品',
product_detail_view: '浏览商品详情',
order_confirm_view: '浏览确认订单',
order_submit: '提交订单',
order_list_view: '浏览订单列表',
order_detail_view: '浏览订单详情',
order_cancel: '取消订单',
pay_page_view: '进入支付页',
pay_success: '支付成功',
pay_fail: '支付失败',
wechat_login: '微信登录',
wechat_phone: '微信授权手机号',
wechat_phone_login: '微信手机号登录',
wechat_location: '微信定位',
wechat_album: '微信选图',
wechat_bind: '绑定微信',
store_list_view: '浏览门店列表',
store_detail_view: '浏览门店详情',
benefit_page_view: '浏览权益页',
benefit_detail_view: '浏览权益详情',
benefit_redeem_start: '开始核销',
benefit_redeem_success: '核销成功',
redeem_code_view: '查看核销码',
redeem_success_view: '核销成功页',
profile_update: '修改资料',
bind_phone: '绑定手机号',
address_list_view: '浏览地址列表',
address_edit: '编辑地址',
promo_touch: '推广触达',
promo_share: '分享推广',
cs_contact: '联系客服',
after_sale_list_view: '浏览售后列表',
after_sale_apply: '申请售后',
client_error: '前端异常',
};
export const STORE_LOG_EVENT_LABELS: Record<string, string> = {
store_sms_send: '发送验证码',
store_sms_login: '短信登录',
store_sms_verify_fail: '验证码校验失败',
store_login_success: '登录成功',
store_select: '选择门店',
store_wechat_login: '微信登录',
store_wechat_bind: '绑定微信',
store_home_view: '浏览首页',
store_redeem_entry_view: '进入核销页',
store_redeem_confirm_view: '核销确认页',
store_phone_redeem_view: '手机号核销页',
store_records_view: '浏览核销记录',
store_withdraw_view: '浏览提现页',
store_packages_view: '浏览套餐页',
store_mine_view: '浏览我的',
store_staff_view: '浏览子账号',
store_redeem_preview: '核销预览',
store_redeem_confirm: '确认核销',
store_redeem_confirm_fail: '核销失败',
store_redeem_weaknet_threshold: '弱网阈值',
store_redeem_pending_submit: '提交挂起核销',
store_redeem_pending_complete: '完成挂起核销',
store_redeem_pending_reject: '驳回挂起核销',
store_redeem_phone_lookup_sms: '手机号核销发码',
store_redeem_phone_balance: '查询用户权益余额',
store_redeem_phone_prepare: '手机号核销准备',
store_redeem_scan_start: '扫码核销开始',
store_payout_created: '生成打款单',
store_payout_paid: '打款完成',
store_withdraw_applied: '申请提现',
store_withdraw_paid: '提现已打款',
store_status_change: '变更营业状态',
store_staff_create: '新增子账号',
store_staff_update: '编辑子账号',
store_staff_delete: '删除子账号',
store_staff_permission_update: '更新子账号权限',
store_package_apply: '申请套餐',
store_package_audit_view: '查看套餐审核',
};
export const PARTNER_LOG_EVENT_LABELS: Record<string, string> = {
partner_sms_send: '发送验证码',
partner_sms_login: '短信登录',
partner_sms_verify_fail: '验证码校验失败',
partner_login_success: '登录成功',
partner_wechat_login: '微信登录',
partner_wechat_bind: '绑定微信',
partner_home_view: '浏览首页',
partner_store_list_view: '浏览门店列表',
partner_store_detail_view: '浏览门店详情',
partner_store_create_view: '进入录店页',
partner_order_list_view: '浏览订单列表',
partner_order_detail_view: '浏览订单详情',
partner_proxy_order_view: '进入代下单页',
partner_bills_view: '浏览账单列表',
partner_bill_detail_view: '浏览账单详情',
partner_staff_list_view: '浏览子账号',
partner_leaderboard_view: '浏览排行榜',
partner_weekly_report_view: '浏览周报',
partner_staff_create: '新增子账号',
partner_staff_sms_send: '子账号发验证码',
partner_staff_sms_verify_fail: '子账号验证码失败',
partner_staff_update: '编辑子账号',
partner_staff_delete: '删除子账号',
partner_staff_permission_update: '更新子账号权限',
partner_store_create: '录入门店',
partner_store_status_change: '变更门店状态',
partner_store_audit_approved: '门店审核通过',
partner_store_audit_rejected: '门店审核驳回',
partner_order_ship: '订单发货',
partner_delivery_advance: '推进配送',
partner_proxy_order_create: '代下单',
partner_bill_view: '查看账单',
partner_bill_confirm: '确认账单',
partner_warehouse_view: '浏览仓库',
partner_warehouse_update: '更新仓库',
};
export const EVENT_NAME_LABELS: Record<string, string> = {
...USER_LOG_EVENT_LABELS,
...STORE_LOG_EVENT_LABELS,
...PARTNER_LOG_EVENT_LABELS,
};
export const THIRD_PARTY_PROVIDER_LABELS: Record<string, string> = {
WECHAT_AUTH: '微信授权',
WECHAT_PAY: '微信支付',
WECHAT_REFUND: '微信退款',
WECHAT_MAP: '微信/腾讯地图',
ALIYUN_OSS: '阿里云 OSS',
ALIYUN_SMS: '阿里云短信',
MOCK_SMS: 'Mock 短信',
SMS: '短信',
XFX: '小飞侠',
LOGISTICS: '物流快递',
MANUAL: '手动',
};
export const THIRD_PARTY_STATUS_LABELS: Record<string, string> = {
SUCCESS: '成功',
FAILED: '失败',
PENDING: '处理中',
};
export const THIRD_PARTY_SCENE_LABELS: Record<string, string> = {
LOGIN: '登录',
USERINFO: '用户信息',
JSSDK_CONFIG: 'JSSDK 配置',
BIND_PHONE: '绑定手机号',
GET_LOCATION: '获取定位',
CHOOSE_IMAGE: '选择图片',
ORDER_PAY: '订单支付',
ORDER_REFUND: '订单退款',
UPLOAD_SHIPPING: '上传发货信息',
TRADE_MANAGE_PUSH: '交易结算推送',
ORDER_DISPATCH: '配送下单',
ORDER_DISPATCH_HOLD: '配送暂不下发',
TRACK_CALLBACK: '物流回调',
UPLOAD_TOKEN: '上传凭证',
UPLOAD_PUT_OBJECT: '上传文件',
GEOCODE: '地理编码',
REVERSE_GEOCODE: '逆地理编码',
};
export const WECOM_BOT_ACTION_LABELS: Record<string, string> = {
...WECOM_BOT_PERMISSION_LABELS,
'user.read_sms.start': '查询用户·发送验证码',
'user.read_sms.verify': '查询用户·校验验证码',
};
export const GENERIC_STATUS_LABELS: Record<string, string> = {
...ORDER_STATUS_LABELS,
...LEDGER_TYPE_LABELS,
...THIRD_PARTY_STATUS_LABELS,
PENDING: '待处理',
APPROVED: '已通过',
REJECTED: '已驳回',
ACTIVE: '正常',
DISABLED: '停用',
SUCCESS: '成功',
FAILED: '失败',
OPEN: '营业中',
PAUSED: '已暂停',
CLOSED: '已关闭',
};
export const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
);
const CODE_ZH: Record<string, string> = {
...CLIENT_APP_LABELS,
...REF_TYPE_LABELS,
...DOMAIN_EVENT_TYPE_LABELS,
...EVENT_NAME_LABELS,
...ORDER_STATUS_LABELS,
...ORDER_STATUS_OPERATOR_LABELS,
...LEDGER_TYPE_LABELS,
...HQ_OPERATION_ACTION_LABELS,
...HQ_ROLE_LABELS,
...THIRD_PARTY_PROVIDER_LABELS,
...THIRD_PARTY_STATUS_LABELS,
...THIRD_PARTY_SCENE_LABELS,
...WECOM_BOT_ACTION_LABELS,
...GENERIC_STATUS_LABELS,
...PARAM_DESC_LABELS,
};
export function zhCode(value?: string | null, empty = '—'): string {
if (value == null || value === '') return empty;
return CODE_ZH[value] ?? value;
}
export function eventNameLabel(value?: string | null): string {
return zhLabel(EVENT_NAME_LABELS, value);
}
export function clientAppLabel(value?: string | null): string {
return zhLabel(CLIENT_APP_LABELS, value);
}
export function formatRef(refType?: string | null, refId?: string | null): string {
if (!refType && !refId) return '—';
const type = zhLabel(REF_TYPE_LABELS, refType, refType || '');
return refId ? `${type} #${refId}` : type;
}
export function userEventCategoryLabel(eventName: string, category?: string | null): string {
const key = category || resolveUserLogCategory(eventName);
if (key && USER_LOG_CATEGORY_LABELS[key]) return USER_LOG_CATEGORY_LABELS[key];
return '其他';
}
export function storeEventCategoryLabel(eventName: string, category?: string | null): string {
const key = category || resolveStoreLogCategory(eventName);
const labels = STORE_LOG_CATEGORY_LABELS as Record<string, string>;
if (key && labels[key]) return labels[key];
return '其他';
}
export function partnerEventCategoryLabel(eventName: string, category?: string | null): string {
const key = category || resolvePartnerLogCategory(eventName);
const labels = PARTNER_LOG_CATEGORY_LABELS as Record<string, string>;
if (key && labels[key]) return labels[key];
return '其他';
}
export function domainParamFieldLabel(desc?: string | null, fallbackIndex?: 1 | 2 | 3): string {
if (desc && PARAM_DESC_LABELS[desc]) return PARAM_DESC_LABELS[desc];
if (fallbackIndex) return `参数${fallbackIndex}`;
return '参数';
}
export function zhRemark(value?: string | null): string {
if (!value) return '—';
return value
.replace(/partnerAccountId=/g, '合伙人账号=')
.replace(/hqAccountId=/g, 'HQ账号=')
.replace(/deliveryMode=/g, '履约方式=')
.replace(/\bmode=/g, '履约方式=')
.replace(/customer=/g, '客户=')
.replace(/\bON_SITE_PICKUP\b/g, '现场提货')
.replace(/\bCROSS_CITY\b/g, '跨城')
.replace(/\bLOCAL\b/g, '同城');
}
export function domainParamValue(desc?: string | null, value?: string | null): string {
if (value == null || value === '') return '—';
if (desc === 'from_status' || desc === 'to_status') return zhLabel(ORDER_STATUS_LABELS, value);
if (desc === 'operator') return zhLabel(ORDER_STATUS_OPERATOR_LABELS, value);
if (desc === 'ledger_type') return zhLabel(LEDGER_TYPE_LABELS, value);
if (desc === 'action') return zhLabel(HQ_OPERATION_ACTION_LABELS, value);
if (desc === 'target_type') return zhLabel(REF_TYPE_LABELS, value);
return zhCode(value);
}
+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);
}
+1
View File
@@ -16,6 +16,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' }, { value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' }, { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' }, { value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
{ value: 'USER_ASSOC_UPDATE', label: '修改用户关联合伙人' },
{ value: 'USER_DELETE', label: '删除用户' }, { value: 'USER_DELETE', label: '删除用户' },
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' }, { value: 'USER_BATCH_DELETE', label: '批量删除用户' },
{ value: 'ORDER_SHIP', label: '订单发货' }, { value: 'ORDER_SHIP', label: '订单发货' },
@@ -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,9 @@
/** 表单选填项常为 null,PUT 时去掉以免后端对 null 调 trim */
export function omitNullFields<T extends Record<string, unknown>>(input: T): Partial<T> {
const out: Partial<T> = {};
for (const [key, value] of Object.entries(input)) {
if (value === null || value === undefined) continue;
(out as Record<string, unknown>)[key] = value;
}
return out;
}
+10 -1
View File
@@ -1,3 +1,5 @@
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
export type StoreCreateForm = { export type StoreCreateForm = {
partnerAccountId: string; partnerAccountId: string;
cityId: string; cityId: string;
@@ -15,6 +17,8 @@ export type StoreCreateForm = {
longitude?: number | null; longitude?: number | null;
intro?: string; intro?: string;
benefitUsageRule?: string; benefitUsageRule?: string;
/** 对外联系电话(店长);可与登录号不同,支持座机 */
contactPhone?: string;
openTime?: string; openTime?: string;
closeTime?: string; closeTime?: string;
openTime2?: string; openTime2?: string;
@@ -22,11 +26,13 @@ export type StoreCreateForm = {
avgPrice?: number | null; avgPrice?: number | null;
coverUrl?: string; coverUrl?: string;
envPhotoUrls?: string[]; envPhotoUrls?: string[];
contractUrl?: string; /** 签约合同,支持多张照片 / PDF */
contractUrls?: string[];
bankAccountName: string; bankAccountName: string;
bankAccountNo: string; bankAccountNo: string;
bankBranch: string; bankBranch: string;
settlementRate?: number; settlementRate?: number;
sortOrder?: number;
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[]; visibilityPhones?: string[];
}; };
@@ -57,6 +63,7 @@ export function validateStoreCreateStep1(
| 'openTime2' | 'openTime2'
| 'closeTime2' | 'closeTime2'
| 'avgPrice' | 'avgPrice'
| 'contactPhone'
>, >,
): string | null { ): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人'; if (!form.partnerAccountId) return '请选择开城合伙人';
@@ -66,6 +73,8 @@ export function validateStoreCreateStep1(
if (!form.name?.trim()) return '请填写门店名称'; if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号'; if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
const contactPhone = form.contactPhone?.trim();
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
if (!form.address?.trim()) return '请填写详细地址'; if (!form.address?.trim()) return '请填写详细地址';
const openTime = String(form.openTime || '').trim(); const openTime = String(form.openTime || '').trim();
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api'; import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
@@ -9,14 +14,24 @@ export type UploadFileResult = {
mock: boolean; mock: boolean;
}; };
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */ /** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export async function uploadFileToOss( export async function uploadFileToOss(
file: File, file: File,
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
@@ -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 };
}
+185 -111
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } 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,8 +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 { 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;
@@ -29,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;
@@ -57,14 +107,20 @@ 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 location = useLocation();
const [searchParams] = useSearchParams();
const initialCouponNo = searchParams.get('couponNo')?.trim() || '';
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>>(() => {
const init: Record<string, string> = {};
if (initialCouponNo) init.couponNo = initialCouponNo;
return init;
});
const [grantOpen, setGrantOpen] = useState(false); const [grantOpen, setGrantOpen] = useState(false);
const [granting, setGranting] = useState(false); const [granting, setGranting] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -83,6 +139,35 @@ export default function BenefitCouponsPage() {
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);
const deepLinkOpenedRef = useRef(false);
async function openCouponDetail(id: string) {
setDetail(await request<CouponDetail>(`/admin/benefit/coupons/${id}`));
setDrawerOpen(true);
}
useEffect(() => {
if (initialCouponNo) form.setFieldsValue({ couponNo: initialCouponNo });
}, [form, initialCouponNo]);
useEffect(() => {
const openCouponId = (location.state as { openCouponId?: string } | null)?.openCouponId;
if (openCouponId && !deepLinkOpenedRef.current) {
deepLinkOpenedRef.current = true;
void openCouponDetail(openCouponId).catch((e) => {
message.error(e instanceof Error ? e.message : '加载权益券失败');
});
return;
}
if (!initialCouponNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.couponNo) === initialCouponNo) {
deepLinkOpenedRef.current = true;
void openCouponDetail(first.id).catch((e) => {
message.error(e instanceof Error ? e.message : '加载权益券失败');
});
}
}, [data, initialCouponNo, loading, location.state]);
async function openRedeemDetail(redeemId: string) { async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true); setRedeemDetailLoading(true);
@@ -98,21 +183,57 @@ 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={() => void openCouponDetail(row.id)}>
{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: '操作',
@@ -121,10 +242,7 @@ export default function BenefitCouponsPage() {
<Button <Button
type="link" type="link"
size="small" size="small"
onClick={async () => { onClick={() => void openCouponDetail(row.id)}
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
> >
</Button> </Button>
@@ -154,16 +272,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="券号">
@@ -189,7 +311,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,
@@ -242,7 +364,8 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="权益券详情" title="权益券详情"
width={720} width={980}
styles={{ body: { paddingBottom: 24 } }}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={ extra={
@@ -266,15 +389,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 }}>
@@ -310,52 +455,53 @@ export default function BenefitCouponsPage() {
size="small" size="small"
rowKey="id" rowKey="id"
pagination={false} pagination={false}
tableLayout="fixed"
locale={{ emptyText: '暂无关联核销单' }} locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []} dataSource={detail.redeemRecords ?? []}
columns={[ columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true }, { title: '核销号', dataIndex: 'redeemNo' },
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
width: 70, width: 72,
render: (v: string | undefined) => render: (v: string | undefined) =>
v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>, v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>,
}, },
{ {
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}` : ''}` : '—',
}, },
{ {
title: '本券分摊', title: '本券分摊',
dataIndex: 'couponAmount', dataIndex: 'couponAmount',
width: 95, width: 96,
render: (v: number | undefined, row: CouponRedeemRecord) => render: (v: number | undefined, row: CouponRedeemRecord) =>
`¥${Number(v ?? row.amount).toFixed(2)}`, `¥${Number(v ?? row.amount).toFixed(2)}`,
}, },
{ {
title: '核销总额', title: '核销总额',
dataIndex: 'amount', dataIndex: 'amount',
width: 90, width: 96,
render: (v: number) => `¥${Number(v).toFixed(2)}`, render: (v: number) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '结算额', title: '结算额',
dataIndex: 'settleAmount', dataIndex: 'settleAmount',
width: 90, width: 96,
render: (v: number) => `¥${Number(v).toFixed(2)}`, render: (v: number) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '时间', title: '时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
width: 150, width: 148,
render: (v: string) => fmtTime(v), render: (v: string) => fmtTime(v),
}, },
{ {
title: '操作', title: '操作',
width: 70, width: 64,
render: (_: unknown, row: CouponRedeemRecord) => ( render: (_: unknown, row: CouponRedeemRecord) => (
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}> <Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
@@ -374,7 +520,7 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="核销单详情" title="核销单详情"
width={520} width={640}
open={redeemDrawerOpen} open={redeemDrawerOpen}
onClose={() => { onClose={() => {
setRedeemDrawerOpen(false); setRedeemDrawerOpen(false);
@@ -385,79 +531,7 @@ export default function BenefitCouponsPage() {
{redeemDetailLoading ? ( {redeemDetailLoading ? (
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
) : redeemDetail ? ( ) : redeemDetail ? (
<Descriptions column={1} bordered size="small"> <RedeemRecordDetailDescriptions detail={redeemDetail} />
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="核销额">
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="结算额">
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户">
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店">
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
? `${(redeemDetail.store as { cityName?: string }).cityName}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店地址">
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="合伙人">
{String(
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
?.partnerAccount?.companyName ?? '—',
)}
</Descriptions.Item>
<Descriptions.Item label="权益券号">
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="关联订单">
{String(
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
'—',
)}
</Descriptions.Item>
{Array.isArray(redeemDetail.allocations) &&
(redeemDetail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="券分摊">
{(
redeemDetail.allocations as Array<{
couponNo?: string;
orderNo?: string | null;
amount?: number;
sortOrder?: number;
}>
)
.map((a, idx) => {
const role = idx === 0 ? '主' : '次';
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
a.orderNo ? `(订单 ${a.orderNo}` : ''
}`;
})
.join('')}
</Descriptions.Item>
) : null}
{redeemDetail.payout ? (
<Descriptions.Item label="门店结算单">
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
{' / '}
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
</Descriptions.Item>
) : null}
{redeemDetail.rating ? (
<Descriptions.Item label="评价">
{(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '}
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'}
</Descriptions.Item>
) : null}
</Descriptions>
) : null} ) : null}
</Drawer> </Drawer>
</div> </div>
@@ -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>
); );
+621
View File
@@ -0,0 +1,621 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { request } from '../lib/api';
import bgmSrc from '../assets/big-screen/dukang-bgm.mp3';
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;
const BGM_VOLUME = 0.32;
const BGM_DUCK_VOLUME = 0.08;
let bgmAudio: HTMLAudioElement | null = null;
let bgmDucked = false;
function getBgmAudio(): HTMLAudioElement | null {
if (typeof Audio === 'undefined') return null;
if (!bgmAudio) {
bgmAudio = new Audio(bgmSrc);
bgmAudio.loop = true;
bgmAudio.preload = 'auto';
bgmAudio.volume = BGM_VOLUME;
}
return bgmAudio;
}
function applyBgmVolume() {
if (!bgmAudio) return;
bgmAudio.volume = bgmDucked ? BGM_DUCK_VOLUME : BGM_VOLUME;
}
function duckBgm() {
bgmDucked = true;
applyBgmVolume();
}
function unduckBgm() {
bgmDucked = false;
applyBgmVolume();
}
function setBgmPlaying(on: boolean): Promise<boolean> {
const audio = getBgmAudio();
if (!audio) return Promise.resolve(false);
applyBgmVolume();
if (!on) {
audio.pause();
return Promise.resolve(false);
}
return audio
.play()
.then(() => true)
.catch(() => false);
}
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;
duckBgm();
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 */
}
unduckBgm();
};
}
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>
);
}
function BgmToggleButton({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button
type="button"
className={`big-screen-bgm${on ? ' is-on' : ''}`}
onClick={onToggle}
aria-pressed={on}
aria-label={on ? '暂停背景音乐' : '播放背景音乐'}
>
<span className="big-screen-bgm-icon" aria-hidden>
{on ? (
<svg viewBox="0 0 24 24" width="16" height="16">
<path
fill="currentColor"
d="M9 18V6h2v12H9zm4 0V6h2v12h-2z"
/>
</svg>
) : (
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M8 5.14v13.72L19.06 12 8 5.14z" />
</svg>
)}
</span>
{on ? 'BGM 播放中' : '播放 BGM'}
</button>
);
}
export default function BigScreenPage() {
const [items, setItems] = useState<BigScreenOrder[]>([]);
const [loadError, setLoadError] = useState('');
const [paused, setPaused] = useState(false);
const [bgmOn, setBgmOn] = useState(false);
const bgmWantedRef = useRef(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]);
const toggleBgm = useCallback(() => {
unlockCelebrateAudio();
const next = !bgmWantedRef.current;
bgmWantedRef.current = next;
if (!next) {
void setBgmPlaying(false);
setBgmOn(false);
return;
}
setBgmOn(true);
void setBgmPlaying(true).then((playing) => {
if (!bgmWantedRef.current) return;
setBgmOn(playing);
});
}, []);
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);
bgmWantedRef.current = false;
void setBgmPlaying(false);
};
}, []);
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">
<BgmToggleButton on={bgmOn} onToggle={toggleBgm} />
<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>
);
}
+50 -15
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
Divider,
Drawer, Drawer,
Form, Form,
Input, Input,
@@ -24,6 +25,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 +228,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 +273,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 +282,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 +323,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 +341,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)}>
@@ -347,8 +363,28 @@ export default function CitiesPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item> </Form.Item>
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item> <Divider orientation="left"></Divider>
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item> <Form.Item
name="pickupMinQty"
label="现场提货"
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="localMinQty"
label="同城配送"
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="crossMinQty"
label="跨城配送"
extra="按瓶当量。当前箱规 6 瓶/箱时,填 6 = 最少 1 箱。"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item <Form.Item
name="maxPartnerCommissionPercent" name="maxPartnerCommissionPercent"
label="合伙人佣金合计上限 %" label="合伙人佣金合计上限 %"
@@ -623,9 +659,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 +676,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 || '—',
}, },
]} ]}
+93 -33
View File
@@ -31,11 +31,17 @@ import {
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 { omitNullFields } from '../lib/omit-null-fields';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; 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 PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type SubRow = PartnerSubAccountRow; type SubRow = PartnerSubAccountRow;
@@ -56,9 +62,11 @@ type Row = {
managedWarehouseName?: string | null; managedWarehouseName?: string | null;
maxPartnerCommissionRate?: number | null; maxPartnerCommissionRate?: number | null;
storeCount: number; storeCount: number;
assocUserCount?: number;
accountCount: number; accountCount: number;
subAccounts?: SubRow[]; subAccounts?: SubRow[];
createdAt: string; createdAt: string;
isTest?: boolean;
}; };
type PartnerDetail = Row & { type PartnerDetail = Row & {
@@ -124,14 +132,15 @@ export default function CityPartnersPage() {
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 [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners', '/admin/partners',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName); if (filters.companyName) qs.set('companyName', String(filters.companyName));
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', filters.cityId); if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -203,7 +212,7 @@ export default function CityPartnersPage() {
await request(`/admin/partners/${detail.id}`, { await request(`/admin/partners/${detail.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
...v, ...omitNullFields(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,
@@ -259,18 +268,34 @@ 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: 'name', width: 100, ellipsis: true }, title: '公司名',
dataIndex: 'companyName',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '主账号姓名',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '登录手机', dataIndex: 'phone', width: 120 }, { title: '登录手机', dataIndex: 'phone', width: 120 },
{ {
title: '管辖', title: '管辖',
@@ -298,7 +323,6 @@ export default function CityPartnersPage() {
title: '管仓仓库', title: '管仓仓库',
dataIndex: 'managedWarehouseName', dataIndex: 'managedWarehouseName',
width: 110, width: 110,
ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ {
@@ -311,6 +335,19 @@ export default function CityPartnersPage() {
</Link> </Link>
), ),
}, },
{
title: '关联用户',
dataIndex: 'assocUserCount',
width: 80,
render: (n: number, row) => (
<Link
to={`/users?assocPartnerAccountId=${encodeURIComponent(row.id)}`}
title={`查看「${row.companyName || row.phone}」关联用户`}
>
{n ?? 0}
</Link>
),
},
{ 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 },
{ {
@@ -325,30 +362,37 @@ 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" <>
onClick={() => { <Link to="/users?assocPartnerAccountId=any"></Link>
createForm.resetFields(); <Button
createForm.setFieldsValue({ type="primary"
orderCommissionRate: 0, onClick={() => {
redeemCommissionRate: 3, createForm.resetFields();
scopeType: CityPartnerScopeType.CITY_WIDE, createForm.setFieldsValue({
}); orderCommissionRate: 0,
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); redeemCommissionRate: 3,
setCreateMaxRate(0.05); scopeType: CityPartnerScopeType.CITY_WIDE,
setCreateCityCode(undefined); });
setCreateOpen(true); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
}} setCreateMaxRate(0.05);
> setCreateCityCode(undefined);
setCreateOpen(true);
</Button> }}
</Space> >
</Button>
</>
}
/>
<Alert <Alert
type="info" type="info"
@@ -387,6 +431,9 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="手机"> <Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" /> <Input allowClear placeholder="登录手机" />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -411,7 +458,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) => (
@@ -458,6 +505,14 @@ export default function CityPartnersPage() {
{detail.storeCount ?? 0} {detail.storeCount ?? 0}
</Link> </Link>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="关联用户">
<Link
to={`/users?assocPartnerAccountId=${encodeURIComponent(detail.id)}`}
title="查看该城市合伙人关联用户"
>
{detail.assocUserCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}> <Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? ( {detail.managedWarehouseName ?? (
<Typography.Text type="secondary"> <Typography.Text type="secondary">
@@ -542,6 +597,11 @@ export default function CityPartnersPage() {
/> />
), ),
}, },
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]} ]}
/> />
</> </>
+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>
); );
} }
+273 -22
View File
@@ -1,18 +1,79 @@
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 { 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 Row = { type DeliveryOrder = {
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string; id: string;
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: 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 = {
id: string;
orderId: string;
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
updatedAt: string;
logisticsFee?: number | null;
order?: DeliveryOrder;
};
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>>({});
@@ -29,40 +90,165 @@ export default function DeliveriesPage() {
); );
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 [trackOpen, setTrackOpen] = useState(false);
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
const columns: ColumnsType<Row> = [ function openTrack(row: Row) {
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 }, const orderId = row.orderId || row.order?.id;
{ title: 'provider', dataIndex: 'provider', width: 90 }, if (!orderId) {
message.warning('缺少关联订单,无法查询路由');
return;
}
setTrackOrderId(orderId);
setTrackOrderNo(row.order?.orderNo ?? null);
setTrackOrderStatus(row.order?.status ?? null);
setTrackOpen(true);
}
async function openDetail(row: Row) {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
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: 80, title: '操作', width: 140,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={async () => { <Space size={0}>
const d = await request<Row>(`/admin/deliveries/${row.id}`); <Button type="link" size="small" onClick={() => openTrack(row)}></Button>
setDetail(d); <Button type="link" size="small" onClick={() => void openDetail(row)}></Button>
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo }); </Space>
setDrawerOpen(true);
}}></Button>
), ),
}, },
]; ];
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>
@@ -79,17 +265,82 @@ 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>
<Button
style={{ marginTop: 8 }}
onClick={() => openTrack(detail)}
>
</Button>
</> </>
)} )}
</Drawer> </Drawer>
<OrderTrackDrawer
open={trackOpen}
orderId={trackOrderId}
orderNo={trackOrderNo}
orderStatus={trackOrderStatus}
onClose={() => setTrackOpen(false)}
/>
</div> </div>
); );
} }
+266 -25
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Button, Button,
Form, Form,
Image,
Input, Input,
Modal, Modal,
Popconfirm, Popconfirm,
@@ -16,13 +17,23 @@ import type { ColumnsType } from 'antd/es/table';
import { import {
DEV_PLAN_TASK_STATUS_LABELS, DEV_PLAN_TASK_STATUS_LABELS,
DEV_PLAN_TASK_TYPE_LABELS, DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto, type DevPlanTaskDto,
type DevPlanTaskExportFormat,
type DevPlanTaskStatusDto, type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto, type DevPlanTaskTypeDto,
type DevPlanVersionDto,
type SupportTicketDto,
} 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,
@@ -46,11 +57,32 @@ export default function DevPlanTasksPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [dispatchOpen, setDispatchOpen] = useState(false); const [dispatchOpen, setDispatchOpen] = useState(false);
const [batchEditOpen, setBatchEditOpen] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null); const [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 }>();
useEffect(() => {
if (!modalOpen && !batchEditOpen) return;
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
.then((res) => setVersions(res.items ?? []))
.catch(() => setVersions([]));
}, [modalOpen, batchEditOpen]);
useEffect(() => {
if (!modalOpen || editing) return;
request<{ items: SupportTicketDto[] }>('/admin/support-tickets?pageSize=100')
.then((res) => setSupportTickets(res.items ?? []))
.catch(() => setSupportTickets([]));
}, [modalOpen, editing]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
'/admin/dev-plan/tasks', '/admin/dev-plan/tasks',
@@ -66,30 +98,46 @@ export default function DevPlanTasksPage() {
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO' }); 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,
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
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,
versionIds: values.versionIds ?? [],
}),
}); });
message.success('已更新'); message.success('已更新');
} else { } else {
await request('/admin/dev-plan/tasks', { await request('/admin/dev-plan/tasks', {
method: 'POST', method: 'POST',
body: JSON.stringify({ content: values.content, type: values.type }), body: JSON.stringify({
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
attachmentUrls,
}),
}); });
message.success('已创建'); message.success('已创建');
} }
@@ -119,6 +167,38 @@ export default function DevPlanTasksPage() {
setDispatchOpen(true); setDispatchOpen(true);
} }
function openBatchEdit() {
batchForm.setFieldsValue({ status: undefined, versionId: undefined });
setBatchEditOpen(true);
}
async function submitBatchEdit() {
const values = await batchForm.validateFields();
if (!values.status && !values.versionId) {
message.warning('请至少选择状态或关联版本');
return;
}
setBatchSaving(true);
try {
await request('/admin/dev-plan/tasks/batch-update', {
method: 'POST',
body: JSON.stringify({
taskIds: selectedRowKeys,
status: values.status || undefined,
versionId: values.versionId || undefined,
}),
});
message.success('批量更新成功');
setBatchEditOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量更新失败');
} finally {
setBatchSaving(false);
}
}
async function submitDispatch() { async function submitDispatch() {
const values = await dispatchForm.validateFields(); const values = await dispatchForm.validateFields();
setDispatching(true); setDispatching(true);
@@ -141,8 +221,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',
@@ -157,8 +272,39 @@ 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: 'versions',
width: 140,
render: (_, row) =>
row.versions?.length ? (
<Space size={4} wrap>
{row.versions.map((v) => (
<Tag key={v.id}>{v.versionNo}</Tag>
))}
</Space>
) : (
'—'
),
},
{ title: '创建人', dataIndex: 'creatorName', width: 90 }, { title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -179,21 +325,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={openDispatch}> <>
<Select
</Button> value={exportFormat}
<Button type="primary" onClick={openCreate}> style={{ width: 120 }}
onChange={setExportFormat}
</Button> options={[
</Space> { value: 'markdown', label: 'Markdown' },
</div> { value: 'docx', label: 'Word' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'pdf', label: 'PDF' },
]}
/>
<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"
@@ -226,7 +396,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,
@@ -255,10 +425,58 @@ export default function DevPlanTasksPage() {
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
{editing ? ( {editing ? (
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <>
<Select options={STATUS_OPTIONS} /> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="versionIds" label="关联版本">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="可选:关联版本"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
</>
) : (
<Form.Item name="supportTicketId" label="绑定技术支持工单">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="可选:关联工单"
options={supportTickets.map((t) => ({
value: t.id,
label: `${t.ticketNo} · ${SUPPORT_TICKET_STATUS_LABELS[t.status]} · ${t.title}`,
}))}
/>
</Form.Item> </Form.Item>
) : null} )}
<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>
@@ -280,6 +498,29 @@ export default function DevPlanTasksPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title="批量编辑任务"
open={batchEditOpen}
onCancel={() => setBatchEditOpen(false)}
onOk={() => void submitBatchEdit()}
confirmLoading={batchSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary"> {selectedRowKeys.length} </Typography.Paragraph>
<Form form={batchForm} layout="vertical">
<Form.Item name="status" label="状态(不修改请留空)">
<Select allowClear placeholder="不修改" options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="versionId" label="关联版本(追加关联,不修改请留空)">
<Select
allowClear
placeholder="不修改"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
@@ -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,
+55 -11
View File
@@ -4,6 +4,17 @@ 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 {
DOMAIN_EVENT_TYPE_LABELS,
domainParamFieldLabel,
domainParamValue,
formatRef,
zhCode,
zhLabel,
} from '../lib/display-labels';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = { type Row = {
id: string; id: string;
@@ -12,8 +23,11 @@ type Row = {
refId: string; refId: string;
status?: string | null; status?: string | null;
param1?: string | null; param1?: string | null;
param1Desc?: string | null;
param2?: string | null; param2?: string | null;
param2Desc?: string | null;
param3?: string | null; param3?: string | null;
param3Desc?: string | null;
remark?: string | null; remark?: string | null;
createdAt: string; createdAt: string;
}; };
@@ -55,12 +69,30 @@ 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: '关联', render: (_, r) => `${r.refType} #${r.refId}` }, title: '类型',
{ title: '状态', dataIndex: 'status', width: 100 }, dataIndex: 'eventType',
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' }, width: 130,
render: (v, r) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
}}
>
<Tag>{zhLabel(DOMAIN_EVENT_TYPE_LABELS, v)}</Tag>
</AdminPrimaryLink>
),
},
{ title: '关联', render: (_, r) => formatRef(r.refType, r.refId) },
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => zhCode(v) },
{
title: '摘要',
render: (_, r) => domainParamValue(r.param1Desc, r.param1) !== '—'
? domainParamValue(r.param1Desc, r.param1)
: (r.remark || '—'),
},
{ {
title: '操作', title: '操作',
width: 80, width: 80,
@@ -76,8 +108,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 }}
@@ -112,11 +149,18 @@ export default function DomainEventsPage() {
{detail && ( {detail && (
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item> <Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item> <Descriptions.Item label="类型">{zhLabel(DOMAIN_EVENT_TYPE_LABELS, detail.eventType)}</Descriptions.Item>
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item> <Descriptions.Item label="关联">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item> <Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item> <Descriptions.Item label={domainParamFieldLabel(detail.param1Desc, 1)}>
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item> {domainParamValue(detail.param1Desc, detail.param1)}
</Descriptions.Item>
<Descriptions.Item label={domainParamFieldLabel(detail.param2Desc, 2)}>
{domainParamValue(detail.param2Desc, detail.param2)}
</Descriptions.Item>
<Descriptions.Item label={domainParamFieldLabel(detail.param3Desc, 3)}>
{domainParamValue(detail.param3Desc, detail.param3)}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item> <Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
</Descriptions> </Descriptions>
@@ -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 }]}>
@@ -297,6 +349,7 @@ export default function FulfillmentProvidersPage() {
<Form.Item <Form.Item
name="baseBottles" name="baseBottles"
label="起送瓶数" label="起送瓶数"
extra="物流计价起步瓶数,不影响用户起购(起购在城市管理)"
rules={[{ required: true }]} rules={[{ required: true }]}
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
> >
+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: '请输入手机号' }]}>
+31 -13
View File
@@ -7,8 +7,13 @@ import { useSearchParams } from 'react-router-dom';
import { request } from '../lib/api'; 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 { formatRef, zhCode } from '../lib/display-labels';
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 +57,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,9 +73,19 @@ 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) => zhCode(v) },
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' }, { title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
{ {
title: '操作', title: '操作',
@@ -92,12 +106,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}
@@ -126,7 +144,7 @@ export default function HqLogsPage() {
/> />
</Form.Item> </Form.Item>
<Form.Item name="refType" label="对象类型"> <Form.Item name="refType" label="对象类型">
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} /> <Input allowClear placeholder="对象编码" style={{ width: 140 }} />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
@@ -150,7 +168,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,
@@ -175,12 +193,12 @@ export default function HqLogsPage() {
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null} secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/> />
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item> <Descriptions.Item label="角色">{zhCode(detail.hqRole)}</Descriptions.Item>
<Descriptions.Item label="行为"> <Descriptions.Item label="行为">
{detail.actionLabel || resolveHqOperationLabel(detail.action)} {detail.actionLabel || resolveHqOperationLabel(detail.action)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item> <Descriptions.Item label="对象">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item> <Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item> <Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
</Descriptions> </Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>/</Typography.Title> <Typography.Title level={5} style={{ marginTop: 16 }}>/</Typography.Title>
+88 -56
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>
@@ -234,7 +274,7 @@ export default function HqPermissionsPage() {
<Alert <Alert
type="info" type="info"
showIcon showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。" message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市、修改用户关联合伙人请到「按用户分配」为具体账号勾选。"
/> />
) : ( ) : (
<> <>
@@ -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="抬头类型"
+114 -31
View File
@@ -18,12 +18,17 @@ import type { ColumnsType } from 'antd/es/table';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import type { import type {
KnowledgeBaseDto, KnowledgeBaseDto,
KnowledgeDocumentDetailDto,
KnowledgeDocumentDto, KnowledgeDocumentDto,
} 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 { 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;
@@ -49,6 +54,10 @@ export default function KnowledgeBasesPage() {
const [docsLoading, setDocsLoading] = useState(false); const [docsLoading, setDocsLoading] = useState(false);
const [docModalOpen, setDocModalOpen] = useState(false); const [docModalOpen, setDocModalOpen] = useState(false);
const [docSaving, setDocSaving] = useState(false); const [docSaving, setDocSaving] = useState(false);
const [editingDoc, setEditingDoc] = useState<KnowledgeDocumentDto | null>(null);
const [editDocModalOpen, setEditDocModalOpen] = useState(false);
const [editDocSaving, setEditDocSaving] = useState(false);
const [editDocForm] = Form.useForm<DocForm>();
const { data, loading, page, pageSize, setPage, setPageSize, reload } = const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<KnowledgeBaseDto>( useAdminList<KnowledgeBaseDto>(
@@ -119,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: '启用',
@@ -179,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',
@@ -195,42 +210,66 @@ export default function KnowledgeBasesPage() {
}, },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
render: (_, row) => render: (_, row) =>
drawerKb?.canEditFull ? ( drawerKb?.canEditFull ? (
<Popconfirm <Space>
title="删除文档?" <Button
onConfirm={async () => { type="link"
try { size="small"
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, { onClick={async () => {
method: 'DELETE', if (!drawerKb) return;
}); try {
message.success('已删除'); const detail = await request<KnowledgeDocumentDetailDto>(
void loadDocs(drawerKb); `/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`,
reload(); );
} catch (e) { setEditingDoc(row);
message.error(e instanceof Error ? e.message : String(e)); editDocForm.setFieldsValue({
} title: detail.title,
}} contentText: detail.contentText || '',
> });
<Button type="link" size="small" danger> setEditDocModalOpen(true);
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
</Button> </Button>
</Popconfirm> <Popconfirm
title="删除文档?"
onConfirm={async () => {
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
method: 'DELETE',
});
message.success('已删除');
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
) : null, ) : null,
}, },
]; ];
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}
@@ -398,6 +437,50 @@ export default function KnowledgeBasesPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title="编辑知识文档"
open={editDocModalOpen}
onCancel={() => {
setEditDocModalOpen(false);
setEditingDoc(null);
}}
confirmLoading={editDocSaving}
onOk={async () => {
if (!drawerKb || !editingDoc) return;
const values = await editDocForm.validateFields();
setEditDocSaving(true);
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${editingDoc.id}`, {
method: 'PUT',
body: JSON.stringify({
title: values.title,
contentText: values.contentText || null,
}),
});
message.success('已保存');
setEditDocModalOpen(false);
setEditingDoc(null);
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setEditDocSaving(false);
}
}}
destroyOnClose
width={640}
>
<Form form={editDocForm} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contentText" label="正文(可粘贴)">
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
+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"
+604 -122
View File
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Checkbox,
Collapse, Collapse,
DatePicker,
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
@@ -19,17 +21,27 @@ 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 { DownOutlined, UpOutlined } from '@ant-design/icons';
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,
fmtTime, fmtTime,
} from '../lib/constants'; } from '../lib/constants';
import { zhRemark } from '../lib/display-labels';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types'; import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
import ProxyOrderModal from '../components/ProxyOrderModal'; import ProxyOrderModal from '../components/ProxyOrderModal';
import OrderTrackDrawer from '../components/OrderTrackDrawer';
type ShipDefaults = { type ShipDefaults = {
provider: string; provider: string;
@@ -45,6 +57,7 @@ type ShipDefaults = {
}; };
type CityOption = { id: string; name: string; code: string }; type CityOption = { id: string; name: string; code: string };
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
type WarehouseOption = { type WarehouseOption = {
id: string; id: string;
@@ -81,6 +94,20 @@ type OrderRedeemSummary = {
redeemRecordSum: number; redeemRecordSum: number;
}; };
type AssocPartnerBrief = {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
orderCommissionRate?: number | null;
};
function formatAssocPartner(p?: AssocPartnerBrief | null) {
if (!p) return '—';
const title = p.companyName || p.name;
return [title, p.phone].filter(Boolean).join(' / ') || '—';
}
type OrderDetail = AdminOrderRow & { type OrderDetail = AdminOrderRow & {
receiverAddress?: string; receiverAddress?: string;
receiverProvince?: string; receiverProvince?: string;
@@ -124,10 +151,143 @@ type OrderDetail = AdminOrderRow & {
lng?: number | null; lng?: number | null;
lat?: number | null; lat?: number | null;
} | null; } | null;
assocPartnerAtPay?: AssocPartnerBrief | null;
orderCommissionRateAtPay?: number | null;
user?: AdminOrderRow['user'] & {
assocPartner?: AssocPartnerBrief | null;
};
}; };
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;
userKeyword?: string;
productKeyword?: string;
fulfillmentHold?: boolean;
deliveryType?: string;
promoCodeId?: string;
assocPartnerAccountId?: 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: '当年' },
];
const FILTERS_COLLAPSED_KEY = 'admin-orders-filters-collapsed';
function formatUserWithRemark(user?: {
userNo?: string;
hqRemark?: string | null;
} | null) {
const userNo = user?.userNo || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${userNo}${remark}` : userNo;
}
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.userKeyword) payload.userKeyword = filters.userKeyword;
if (filters.productKeyword) payload.productKeyword = filters.productKeyword;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
if (filters.assocPartnerAccountId) payload.assocPartnerAccountId = filters.assocPartnerAccountId;
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 [];
@@ -160,6 +320,16 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
} }
export default function OrdersPage() { export default function OrdersPage() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
const initialStatusParam = searchParams.getAll('status').join(',');
const initialStatuses = useMemo(
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
[initialStatusParam],
);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [shipForm] = Form.useForm(); const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm(); const [logisticsForm] = Form.useForm();
@@ -170,6 +340,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);
@@ -181,13 +352,25 @@ export default function OrdersPage() {
const [shipping, setShipping] = useState(false); const [shipping, setShipping] = useState(false);
const [logisticsShipping, setLogisticsShipping] = useState(false); const [logisticsShipping, setLogisticsShipping] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]); const [cities, setCities] = useState<CityOption[]>([]);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [shipModalOpen, setShipModalOpen] = useState(false); const [shipModalOpen, setShipModalOpen] = useState(false);
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null); const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS'); const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
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 [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
try {
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
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)),
@@ -198,6 +381,35 @@ 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 (initialStatuses.length) {
form.setFieldsValue({ status: initialStatuses });
}
}, [form, initialStatuses]);
useEffect(() => {
const openOrderId = (location.state as { openOrderId?: string } | null)?.openOrderId;
if (openOrderId) {
void openDetail(openOrderId);
navigate(`${location.pathname}${location.search}`, { replace: true, state: null });
}
}, [location.state, location.pathname, location.search, navigate]);
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);
@@ -217,18 +429,28 @@ 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); const statuses = selectedStatuses(values.status);
const statusList = statuses.length ? statuses : initialStatuses;
for (const status of statusList) 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.userKeyword) qs.set('userKeyword', values.userKeyword);
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId);
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
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, initialPromoCodeId, initialStatuses]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -239,6 +461,9 @@ export default function OrdersPage() {
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(() => {}); .catch(() => {});
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []); }, []);
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) { function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
@@ -429,69 +654,144 @@ export default function OrdersPage() {
} }
} }
const columns: ColumnsType<AdminOrderRow> = [ async function submitExport(scope: OrderExportScope) {
{ title: '订单号', dataIndex: 'orderNo', width: 180 }, const values = form.getFieldsValue() as OrderExportFilters;
if (scope === 'selected' && !selectedRowKeys.length) {
message.warning('请先勾选要导出的订单');
return;
}
setExporting(true);
try {
const payload = buildExportPayload(
scope,
exportFormat,
{
...values,
promoCodeId: initialPromoCodeId || values.promoCodeId,
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
},
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: '订单号',
width: 90, dataIndex: 'orderNo',
render: (_, row) => row.city?.name || '—', width: 180,
render: (v, row) => (
<Space size={4}>
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{
title: '用户',
key: 'user',
width: 200,
render: (_, row) =>
row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}>
{formatUserWithRemark(row.user)}
</AdminPrimaryLink>
) : (
formatUserWithRemark(row.user)
),
}, },
{ {
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: '操作',
@@ -523,27 +823,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
@@ -559,51 +860,181 @@ export default function OrdersPage() {
/> />
) : null} ) : null}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> {initialPromoCodeId ? (
<Form.Item name="orderNo" label="订单号"> <Alert
<Input placeholder="DK..." allowClear /> type="info"
</Form.Item> showIcon
<Form.Item name="status" label="状态"> closable
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> style={{ marginBottom: 16 }}
</Form.Item> message={
<Form.Item name="orderType" label="类型"> initialStatuses.includes('COMPLETED') && initialStatuses.length === 1
<Select ? '已按推广码筛选已完成订单'
allowClear : '已按推广码筛选订单'
style={{ width: 120 }} }
placeholder="全部" action={
options={[ <Button size="small" onClick={() => navigate('/orders')}>
{ value: 'NORMAL', label: '普通订单' },
{ value: 'PROXY', label: '代下单' }, </Button>
]} }
/> onClose={() => navigate('/orders')}
</Form.Item> />
<Form.Item name="cityId" label="城市"> ) : null}
<Select
allowClear <Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
showSearch <div className="admin-orders-filter-row">
optionFilterProp="label" <Form.Item name="cityId" label="城市" className="admin-orders-filter-item admin-orders-filter-item--sm">
style={{ width: 140 }} <Select
placeholder="全部" allowClear
options={cities.map((c) => ({ value: c.id, label: c.name }))} showSearch
/> optionFilterProp="label"
</Form.Item> placeholder="全部"
<Form.Item name="receiverPhone" label="收货手机"> options={cities.map((c) => ({ value: c.id, label: c.name }))}
<Input allowClear /> />
</Form.Item> </Form.Item>
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked"> <Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Select <Input allowClear placeholder="手机号" />
allowClear </Form.Item>
style={{ width: 140 }} <Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
placeholder="全部" <Input allowClear placeholder="编号/昵称/备注/手机" />
options={[{ value: true, label: '仅待确认大单' }]} </Form.Item>
/> <Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
</Form.Item> <Input allowClear placeholder="名称/规格" />
<Form.Item> </Form.Item>
<Space> <Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
<Button type="primary" htmlType="submit"></Button> <Select
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button> mode="multiple"
</Space> allowClear
</Form.Item> maxTagCount="responsive"
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
className="admin-orders-filter-item admin-orders-filter-item--status"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={[
{ value: 'none', label: '未关联' },
...partners.map((p) => ({
value: p.id,
label: formatAssocPartner({
id: p.id,
name: p.name || p.id,
companyName: p.companyName,
phone: p.phone,
}),
})),
]}
/>
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
type="link"
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => {
setFiltersCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}}
>
{filtersCollapsed ? '展开' : '收起'}
</Button>
</Space>
</Form.Item>
</div>
{!filtersCollapsed ? (
<>
<div className="admin-orders-filter-row">
<Form.Item label="下单日期" className="admin-orders-filter-item admin-orders-filter-item--date">
<Space size={8} wrap>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear className="admin-orders-date-picker" />
</Form.Item>
{DATE_PRESETS.map((preset) => (
<Button
key={preset.key}
size="small"
onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
>
{preset.label}
</Button>
))}
</Space>
</Form.Item>
</div>
<div className="admin-orders-filter-row admin-orders-filter-row--tools">
{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>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</div>
</>
) : null}
</Form> </Form>
<Table <Table
@@ -611,16 +1042,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);
@@ -700,7 +1133,33 @@ export default function OrdersPage() {
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'} {[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
</Descriptions.Item> </Descriptions.Item>
) : null} ) : null}
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item> <Descriptions.Item label="关联合伙人(本单佣金)">
{detail.assocPartnerAtPay
? `${formatAssocPartner(detail.assocPartnerAtPay)}${
detail.assocPartnerAtPay.orderCommissionRate != null
? ` · 费率 ${(Number(detail.assocPartnerAtPay.orderCommissionRate) * 100).toFixed(2)}%`
: ''
}`
: '—'}
</Descriptions.Item>
{detail.user?.assocPartner &&
detail.user.assocPartner.id !== detail.assocPartnerAtPay?.id ? (
<Descriptions.Item label="用户当前关联">
{formatAssocPartner(detail.user.assocPartner)}
</Descriptions.Item>
) : null}
<Descriptions.Item label="用户">
{detail.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate(`/users?userId=${encodeURIComponent(String(detail.user!.id))}`)}
>
{formatUserWithRemark(detail.user)}
</AdminPrimaryLink>
) : (
formatUserWithRemark(detail.user)
)}
{detail.user?.nickname ? ` / ${detail.user.nickname}` : ''}
</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item> <Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item> <Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item> <Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
@@ -793,7 +1252,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',
@@ -804,7 +1263,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}` : ''}` : '—',
}, },
@@ -859,7 +1317,20 @@ export default function OrdersPage() {
</Descriptions> </Descriptions>
{detail.delivery && ( {detail.delivery && (
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}> <Descriptions
column={1}
bordered
size="small"
title={
<Space>
<span></span>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setTrackOpen(true)}>
</Button>
</Space>
}
style={{ marginTop: 16 }}
>
<Descriptions.Item label="快递公司"> <Descriptions.Item label="快递公司">
{detail.delivery.logisticsCompany || {detail.delivery.logisticsCompany ||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
@@ -867,6 +1338,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">
@@ -907,8 +1383,7 @@ export default function OrdersPage() {
{ {
title: '备注', title: '备注',
dataIndex: 'remark', dataIndex: 'remark',
ellipsis: true, render: (v) => zhRemark(v),
render: (v) => v || '—',
}, },
{ {
title: '时间', title: '时间',
@@ -1202,7 +1677,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}` : ''}`
@@ -1232,6 +1706,14 @@ export default function OrdersPage() {
void openDetail(order.id); void openDetail(order.id);
}} }}
/> />
<OrderTrackDrawer
open={trackOpen}
orderId={detail?.id ?? null}
orderNo={detail?.orderNo}
orderStatus={detail?.status}
onClose={() => setTrackOpen(false)}
/>
</div> </div>
); );
} }
@@ -5,8 +5,12 @@ 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';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
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 +173,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 +255,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 +309,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 +403,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,9 +411,16 @@ 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' }} />
), ),
}, },
...(!detail.parentAccountId
? [{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
}]
: []),
]} /> ]} />
)} )}
</Drawer> </Drawer>
+227 -19
View File
@@ -3,6 +3,8 @@ import {
Button, Button,
Card, Card,
DatePicker, DatePicker,
Descriptions,
Drawer,
Form, Form,
Input, Input,
Modal, Modal,
@@ -10,6 +12,7 @@ import {
Space, Space,
Statistic, Statistic,
Table, Table,
Tabs,
Tag, Tag,
Typography, Typography,
message, message,
@@ -20,6 +23,23 @@ 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 BillItemRow = {
id: string;
kind: 'ORDER' | 'REDEEM';
refId: string;
refNo: string;
title?: string | null;
extra?: string | null;
baseAmount: number;
rate: number;
commission: number;
occurredAt: string;
};
type Row = { type Row = {
id: string; id: string;
@@ -31,8 +51,24 @@ 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;
orderItems?: BillItemRow[];
redeemItems?: BillItemRow[];
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 +113,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 +165,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 +233,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 +258,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 +302,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 +333,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 +361,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 +472,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 +542,137 @@ export default function PartnerBillsPage() {
</Button> </Button>
</Form> </Form>
</Modal> </Modal>
<Drawer
title="合伙人账单明细"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={720}
>
{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>
<Tabs
style={{ marginTop: 16 }}
items={[
{
key: 'orders',
label: `酒订单 (${detail.orderItems?.length ?? 0})`,
children: (
<>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.orderItems ?? []}
columns={[
{ title: '订单号', dataIndex: 'refNo', width: 160 },
{ title: '商品', dataIndex: 'title' },
{ title: '数量', dataIndex: 'extra', width: 70 },
{
title: '实付',
dataIndex: 'baseAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '费率',
dataIndex: 'rate',
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{
title: '佣金',
dataIndex: 'commission',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
]}
/>
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
¥{Number(detail.orderCommission).toFixed(2)}
</Typography.Paragraph>
</>
),
},
{
key: 'redeems',
label: `核销订单 (${detail.redeemItems?.length ?? 0})`,
children: (
<>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.redeemItems ?? []}
columns={[
{ title: '核销单号', dataIndex: 'refNo', width: 160 },
{ title: '门店', dataIndex: 'title' },
{
title: '核销额',
dataIndex: 'baseAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '费率',
dataIndex: 'rate',
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{
title: '佣金',
dataIndex: 'commission',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
]}
/>
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
¥{Number(detail.redeemCommission).toFixed(2)}
</Typography.Paragraph>
</>
),
},
]}
/>
<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>
); );
} }
+45 -22
View File
@@ -4,16 +4,19 @@ import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
PARTNER_LOG_CATEGORY_OPTIONS, PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
PARTNER_STAFF_ROLE_LABELS, PARTNER_STAFF_ROLE_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory, type PartnerLogCategory,
type PartnerStaffRole, type PartnerStaffRole,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; 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 { clientAppLabel, eventNameLabel, formatRef, partnerEventCategoryLabel } from '../lib/display-labels';
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 +74,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 +90,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}
@@ -105,18 +106,31 @@ export default function PartnerLogsPage() {
dataIndex: 'category', dataIndex: 'category',
width: 100, width: 100,
render: (v: PartnerLogCategory | null, r) => ( render: (v: PartnerLogCategory | null, r) => (
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag> <Tag>{partnerEventCategoryLabel(r.eventName, v)}</Tag>
),
},
{
title: '事件',
dataIndex: 'eventName',
width: 180,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
{eventNameLabel(v)}
</AdminPrimaryLink>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 180 },
{ {
title: '关联', title: '关联',
width: 120, width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => formatRef(r.refType, r.refId),
}, },
{ {
title: '摘要', title: '摘要',
ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson), render: (_, r) => summarizeExtra(r.extraJson),
}, },
{ {
@@ -137,11 +151,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}
@@ -172,11 +187,11 @@ export default function PartnerLogsPage() {
<Form.Item name="companyName" label="公司"> <Form.Item name="companyName" label="公司">
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} /> <Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
</Form.Item> </Form.Item>
<Form.Item name="partnerId" label="合伙人ID"> <Form.Item name="partnerId" label="合伙人 ID">
<Input placeholder="partnerId" allowClear style={{ width: 120 }} /> <Input placeholder="合伙人 ID" allowClear style={{ width: 120 }} />
</Form.Item> </Form.Item>
<Form.Item name="eventName" label="事件"> <Form.Item name="eventName" label="事件编码">
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} /> <Input placeholder="事件编码" allowClear style={{ width: 160 }} />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
@@ -216,16 +231,24 @@ 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 && (
<Descriptions column={1} bordered size="small"> <Descriptions column={1} bordered size="small">
{Object.entries(detail).map(([k, v]) => ( <Descriptions.Item label="ID">{String(detail.id ?? '—')}</Descriptions.Item>
<Descriptions.Item key={k} label={k}> <Descriptions.Item label="合伙人">{String(detail.companyName || detail.partnerId || '—')}</Descriptions.Item>
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')} <Descriptions.Item label="账号">{String(detail.accountName || detail.accountPhone || '—')}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="分类">{partnerEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}</Descriptions.Item>
))} <Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
<Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
<Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt ? String(detail.createdAt) : null)}</Descriptions.Item>
<Descriptions.Item label="参数">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
</pre>
</Descriptions.Item>
</Descriptions> </Descriptions>
)} )}
</Drawer> </Drawer>
+32 -10
View File
@@ -23,10 +23,16 @@ import {
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 { omitNullFields } from '../lib/omit-null-fields';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; 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';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
type Row = { type Row = {
id: string; id: string;
@@ -158,7 +164,7 @@ export default function PartnersPage() {
try { try {
const v = await editForm.validateFields(); const v = await editForm.validateFields();
const body = { const body = {
...v, ...omitNullFields(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,
@@ -173,17 +179,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 +216,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>
@@ -305,6 +322,11 @@ export default function PartnersPage() {
</> </>
), ),
}, },
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]} ]}
/> />
)} )}
+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;
@@ -34,7 +37,7 @@ function mapToForm(d: Record<string, unknown>) {
const row = d as Row; const row = d as Row;
return { return {
...row, ...row,
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''], detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
features: row.features?.length features: row.features?.length
? row.features ? row.features
: [{ icon: 'star', title: '', desc: '' }], : [{ icon: 'star', title: '', desc: '' }],
@@ -68,12 +71,14 @@ function buildPayload(v: TemplateFormValues) {
function TemplateContentFields() { function TemplateContentFields() {
return ( return (
<> <>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
<DetailImageUrlList <Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
label="详情图" <DetailImageUrlList
bizType="DETAIL_TEMPLATE" label="详情图"
maxCount={TEMPLATE_MAX_DETAIL_IMAGES} bizType="DETAIL_TEMPLATE"
/> maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
/>
</Form.Item>
<Divider /> <Divider />
<Form.Item name="storyTitle" label="故事标题"> <Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" /> <Input placeholder="如:千年杜康 · 唯有此处" />
@@ -134,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 },
@@ -156,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); }}>
@@ -173,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 && (
@@ -228,7 +256,7 @@ export default function ProductDetailTemplatesPage() {
}} width={640}> }} width={640}>
<Form form={createForm} layout="vertical" initialValues={{ <Form form={createForm} layout="vertical" initialValues={{
status: 'ACTIVE', sortOrder: 0, status: 'ACTIVE', sortOrder: 0,
detailImageUrls: [''], detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }], features: [{ icon: 'water_drop', title: '', desc: '' }],
}}> }}>
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic"> <Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
+272 -166
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message, Switch, Table, Tabs, Tag, Typography, message,
@@ -9,9 +9,15 @@ import { request } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { AROMA_TYPE_LABELS, PRODUCT_STATUS_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 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;
@@ -19,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;
@@ -28,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;
@@ -67,21 +95,14 @@ type ProductFormValues = {
features?: Array<{ icon?: string; title?: string; desc?: string }>; features?: Array<{ icon?: string; title?: string; desc?: string }>;
}; };
type UserPickRow = {
id: string;
phone?: string | null;
nickname?: string | null;
userNo?: string;
};
function mapDetailToForm(d: Record<string, unknown>) { function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto; const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row; const row = d as Row;
return { return {
...d, ...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl, coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[], carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[], detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
allowOnlinePurchase: row.allowOnlinePurchase !== false, allowOnlinePurchase: row.allowOnlinePurchase !== false,
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false, allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
allowOnSitePickup: !!row.allowOnSitePickup, allowOnSitePickup: !!row.allowOnSitePickup,
@@ -112,10 +133,6 @@ function buildProductPayload(v: ProductFormValues) {
features: features.length ? features : undefined, features: features.length ? features : undefined,
}; };
const visibilityPhones = (v.visibilityPhones ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean);
return { return {
barcode69: v.barcode69, barcode69: v.barcode69,
name: v.name, name: v.name,
@@ -131,7 +148,6 @@ function buildProductPayload(v: ProductFormValues) {
allowCrossCityDelivery: allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false, v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl, coverUrl: v.coverUrl,
carouselUrls, carouselUrls,
detailImageUrls, detailImageUrls,
@@ -141,25 +157,13 @@ function buildProductPayload(v: ProductFormValues) {
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) { function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return ( return (
<Form.List name={name}> <Form.Item name={name} style={{ marginBottom: 0 }}>
{(fields, { add, remove }) => ( <MultiImageUpload
<> bizType={bizType}
{fields.map((field) => ( mediaType="IMAGE"
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}> tip={`${label}支持一次选择多张批量上传`}
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}> />
<OssUpload bizType={bizType} mediaType="IMAGE" /> </Form.Item>
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
</>
)}
</Form.List>
); );
} }
@@ -168,11 +172,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
<> <>
<ProductDetailTemplatePicker form={form} aromaType={aromaType} /> <ProductDetailTemplatePicker form={form} aromaType={aromaType} />
<Divider /> <Divider />
<Typography.Text type="secondary">CAROUSEL</Typography.Text> <Typography.Text type="secondary">CAROUSEL 10MB</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" /> <ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider /> <Divider />
<Typography.Text type="secondary">DETAIL</Typography.Text> <Typography.Text type="secondary">DETAIL 10MB</Typography.Text>
<DetailImageUrlList label="详情图" bizType="DETAIL" /> <Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
</Form.Item>
<Divider /> <Divider />
<Form.Item name="storyTitle" label="故事标题"> <Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" /> <Input placeholder="如:千年杜康 · 唯有此处" />
@@ -213,33 +219,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
} }
function VisibilityWhitelistFields({ form }: { form: FormInstance }) { function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form); const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return ( return (
@@ -248,52 +227,46 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled" name="visibilityWhitelistEnabled"
label="可见白名单" label="可见白名单"
valuePropName="checked" valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试" extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
> >
<Switch checkedChildren="开" unCheckedChildren="关" /> <Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item> </Form.Item>
{enabled ? ( {enabled ? (
<> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
<Form.Item
name="visibilityPhones" </Typography.Text>
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : null} ) : null}
</> </>
); );
} }
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' && (
@@ -301,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 }))} />
@@ -330,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" />
@@ -405,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>
) }, ) },
@@ -419,26 +435,10 @@ export default function ProductsPage() {
title: '白名单', title: '白名单',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
width: 90, width: 90,
render: (v: boolean, row) => render: (v: boolean) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</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) => (
@@ -451,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 }}
@@ -464,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>
@@ -480,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);
@@ -500,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: '详情页',
@@ -517,6 +605,24 @@ export default function ProductsPage() {
/> />
), ),
}, },
{
key: 'specs',
label: '规格与 SKU',
children: (
<ProductSpecsEditor
key={String(detail.id)}
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>
</> </>
@@ -539,7 +645,7 @@ export default function ProductsPage() {
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false, allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [], visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [''], detailImageUrls: [''], carouselUrls: [], detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }], features: [{ icon: 'water_drop', title: '', desc: '' }],
}}> }}>
<Tabs items={[ <Tabs items={[
+44 -37
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
@@ -14,26 +14,15 @@ 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;
type SceneOption = { value: PromoCodeScene; label: string }; type SceneOption = { value: PromoCodeScene; label: string };
async function downloadQrcode(url: string, filename: string) {
try {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
}
export default function PromoCodesPage() { export default function PromoCodesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
@@ -74,8 +63,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: '场景',
@@ -93,8 +89,26 @@ export default function PromoCodesPage() {
</Tag> </Tag>
), ),
}, },
{ title: '扫码', dataIndex: 'scanCount', width: 70 }, {
{ title: '订单', dataIndex: 'orderCount', width: 70 }, title: '扫码',
dataIndex: 'scanCount',
width: 70,
render: (v: number, row) => (
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}?eventType=SCAN`)}>
{v}
</AdminPrimaryLink>
),
},
{
title: <Tooltip title="仅统计已完成订单"></Tooltip>,
dataIndex: 'orderCount',
width: 70,
render: (v: number, row) => (
<AdminPrimaryLink onClick={() => navigate(`/orders?promoCodeId=${row.id}&status=COMPLETED`)}>
{v}
</AdminPrimaryLink>
),
},
{ {
title: '转化率', title: '转化率',
width: 90, width: 90,
@@ -108,24 +122,13 @@ export default function PromoCodesPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 220, width: 140,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size="small" wrap> <Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}> <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button> </Button>
<Button
type="link"
size="small"
disabled={!row.qrcodeUrl}
onClick={() => {
if (!row.qrcodeUrl) return;
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
}}
>
</Button>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}> <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
</Button> </Button>
@@ -160,12 +163,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 +201,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,
+154 -38
View File
@@ -1,10 +1,16 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; import { useNavigate, useSearchParams } from 'react-router-dom';
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';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
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;
@@ -13,30 +19,93 @@ type Row = {
settleAmount: number; settleAmount: number;
channel?: RedeemChannel; channel?: RedeemChannel;
createdAt: string; createdAt: string;
user?: { userNo: string; phone: string | null }; isTest?: boolean;
user?: {
id?: string;
userNo: string;
phone: string | null;
nickname?: string | null;
hqRemark?: string | null;
};
store?: { name: string; cityName: string }; store?: { name: string; cityName: string };
coupon?: { couponNo: string }; coupon?: { id?: string; couponNo: string };
}; };
function formatNicknameWithRemark(user?: {
nickname?: string | null;
hqRemark?: string | null;
} | null) {
const name = user?.nickname?.trim() || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
}
export default function RedeemRecordsPage() { export default function RedeemRecordsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); 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',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo); if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', String(filters.storeId));
if (filters.channel) qs.set('channel', filters.channel); if (filters.channel) qs.set('channel', String(filters.channel));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
); );
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 deepLinkOpenedRef = useRef(false);
const columns: ColumnsType<Row> = [ useEffect(() => {
{ title: '核销号', dataIndex: 'redeemNo', width: 180 }, if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
}, [form, initialRedeemNo]);
async function openDetail(id: string) {
setDetailLoading(true);
setDrawerOpen(true);
try {
setDetail(await request(`/admin/redeem-records/${id}`));
} finally {
setDetailLoading(false);
}
}
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: '核销号',
dataIndex: 'redeemNo',
width: 200,
render: (v, row) => (
<span>
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}>
</Tag>
) : null}
</span>
),
},
{ {
title: '方式', title: '方式',
dataIndex: 'channel', dataIndex: 'channel',
@@ -50,33 +119,78 @@ export default function RedeemRecordsPage() {
); );
}, },
}, },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 }, { title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) =>
row.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
>
{v || '—'}
</AdminPrimaryLink>
) : (
v || '—'
),
},
{
title: '用户昵称',
dataIndex: ['user', 'nickname'],
width: 160,
render: (_: string | null | undefined, row) =>
row.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
>
{formatNicknameWithRemark(row.user)}
</AdminPrimaryLink>
) : (
formatNicknameWithRemark(row.user)
),
},
{
title: '用户手机',
dataIndex: ['user', 'phone'],
width: 120,
render: (v: string | null | undefined) => v || '—',
},
{ title: '门店', dataIndex: ['store', 'name'] }, { title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, { title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` }, { title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 }, {
title: '券号',
dataIndex: ['coupon', 'couponNo'],
width: 160,
render: (v: string | undefined, row) =>
v ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, {
state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined,
})
}
>
{v}
</AdminPrimaryLink>
) : (
'—'
),
},
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 80, width: 80,
render: (_, row) => ( render: (_, row) => (
<Button <Button type="link" size="small" onClick={() => void openDetail(row.id)}>
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/redeem-records/${row.id}`));
setDrawerOpen(true);
}}
>
</Button> </Button>
), ),
}, },
]; ];
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"
@@ -102,6 +216,9 @@ export default function RedeemRecordsPage() {
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -113,7 +230,7 @@ export default function RedeemRecordsPage() {
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,
@@ -125,22 +242,21 @@ export default function RedeemRecordsPage() {
}, },
}} }}
/> />
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer
{detail && ( title="核销详情"
<Descriptions column={1} bordered size="small"> width={640}
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item> open={drawerOpen}
<Descriptions.Item label="方式"> onClose={() => {
{ setDrawerOpen(false);
REDEEM_CHANNEL_LABELS[ setDetail(null);
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel }}
] destroyOnClose
} >
</Descriptions.Item> {detailLoading ? (
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item> <Typography.Text type="secondary"></Typography.Text>
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item> ) : detail ? (
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item> <RedeemRecordDetailDescriptions detail={detail} />
</Descriptions> ) : null}
)}
</Drawer> </Drawer>
</div> </div>
); );
+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,
+52 -24
View File
@@ -1,11 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
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 { 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 };
@@ -15,6 +19,7 @@ type Row = {
name: string; name: string;
status: string; status: string;
createdAt: string; createdAt: string;
isTest?: boolean;
storeCount?: number; storeCount?: number;
staffCount?: number; staffCount?: number;
bankAccountName?: string | null; bankAccountName?: string | null;
@@ -30,13 +35,14 @@ type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() { export default function StoreAccountsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts', '/admin/store-accounts',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', String(filters.status));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -72,8 +78,25 @@ export default function StoreAccountsPage() {
} }
} }
const columns: ColumnsType<Row> = [ const baseColumns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 }, {
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<Space size={4}>
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '绑定门店', title: '绑定门店',
@@ -126,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"
@@ -162,6 +187,9 @@ export default function StoreAccountsPage() {
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table <Table
@@ -170,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,
+210 -81
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { import {
Button, Button,
Card, Card,
@@ -14,9 +14,11 @@ import {
Statistic, Statistic,
Table, Table,
Tag, Tag,
Tooltip,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import { FinancePayProofModal, PaymentProofGallery } from '../components/FinancePayProof';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { import {
@@ -30,6 +32,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 +50,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,18 +66,29 @@ 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',
}; };
export default function StoreBillsPage() { export default function StoreBillsPage() {
const navigate = useNavigate();
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>(
@@ -92,13 +114,21 @@ export default function StoreBillsPage() {
pendingCount: number; pendingCount: number;
overdueCount: number; overdueCount: number;
} | null>(null); } | null>(null);
const [payModal, setPayModal] = useState<{
ids: string[];
amountHint?: number;
} | null>(null);
const [paySubmitting, setPaySubmitting] = useState(false);
const [withdrawApproveId, setWithdrawApproveId] = useState<string | null>(null);
const [withdrawSubmitting, setWithdrawSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
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,61 +142,11 @@ export default function StoreBillsPage() {
}, []); }, []);
function confirmPay(ids: string[], amountHint?: number) { function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({ setPayModal({ ids, amountHint });
title: '确认打款?',
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
setBatchLoading(true);
try {
await request('/admin/store-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
} finally {
setBatchLoading(false);
}
}
message.success('已确认打款');
setSelectedKeys([]);
reload();
},
});
} }
function approveWithdraw(id: string) { function approveWithdraw(id: string) {
let paymentRef = ''; setWithdrawApproveId(id);
Modal.confirm({
title: '审核通过并标记已结算?',
content: (
<Input
placeholder="打款凭证号(可选)"
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
),
okText: '通过并结算',
cancelText: '取消',
onOk: async () => {
await request(`/admin/store-withdrawals/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
});
message.success('已通过并标记已结算');
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
},
});
} }
function rejectWithdraw(id: string) { function rejectWithdraw(id: string) {
@@ -235,36 +215,53 @@ 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>
), ),
}, },
{ {
title: '日期', title: (
<Tooltip title="T+1 为出账日(昨日 00:00–今日 00:00 窗口中的今天,不是该账单里最后一笔核销时间);手动提现为申请时间">
</Tooltip>
),
dataIndex: 'date', dataIndex: 'date',
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 +279,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 +325,29 @@ 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"> T+1 00:00 00:00
{overdueSummary.overdueCount} {overdueSummary.pendingCount} {overdueSummary && overdueSummary.overdueCount > 0 ? (
</Typography.Text> <div>
) : null} <Typography.Text type="danger">
</Space> {overdueSummary.overdueCount} {overdueSummary.pendingCount}
</Typography.Text>
</div>
) : null}
</>
}
/>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
@@ -359,6 +366,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;
@@ -410,7 +418,7 @@ export default function StoreBillsPage() {
}))} }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="range" label="日"> <Form.Item name="range" label="出账日">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -461,7 +469,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,
@@ -484,7 +492,7 @@ export default function StoreBillsPage() {
<> <>
<Descriptions column={1} size="small" bordered> <Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item> <Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账日"> <Descriptions.Item label="账日">
{String(detail.billDate || '').slice(0, 10)} {String(detail.billDate || '').slice(0, 10)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="应付"> <Descriptions.Item label="应付">
@@ -496,7 +504,35 @@ 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.Item label="凭证照片">
<PaymentProofGallery urls={detail.paymentProofUrls} />
</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>
@@ -508,8 +544,22 @@ export default function StoreBillsPage() {
columns={[ columns={[
{ {
title: '核销单号', title: '核销单号',
render: (_, r) => render: (_, r) => {
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'), const redeemNo = String(
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
);
return redeemNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
}
>
{redeemNo}
</AdminPrimaryLink>
) : (
'—'
);
},
}, },
{ {
title: '金额', title: '金额',
@@ -544,6 +594,9 @@ export default function StoreBillsPage() {
<Descriptions.Item label="打款凭证"> <Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'} {detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="凭证照片">
<PaymentProofGallery urls={detail.paymentProofUrls} />
</Descriptions.Item>
</Descriptions> </Descriptions>
{storeAccount ? ( {storeAccount ? (
<> <>
@@ -578,7 +631,18 @@ export default function StoreBillsPage() {
const payout = r.storePayout as const payout = r.storePayout as
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number } | { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
| undefined; | undefined;
return String(payout?.redeemRecord?.redeemNo || ''); const redeemNo = payout?.redeemRecord?.redeemNo || '';
return redeemNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
}
>
{redeemNo}
</AdminPrimaryLink>
) : (
'—'
);
}, },
}, },
{ {
@@ -603,6 +667,71 @@ export default function StoreBillsPage() {
</> </>
)} )}
</Drawer> </Drawer>
<FinancePayProofModal
open={!!payModal}
title="确认打款?"
hint={`将确认 ${payModal?.ids.length ?? 0} 笔 T+1 门店对账单${
payModal?.amountHint != null ? `,合计约 ¥${payModal.amountHint.toFixed(2)}` : ''
}`}
okText="确认打款"
confirmLoading={paySubmitting}
onCancel={() => setPayModal(null)}
onOk={async (payload) => {
if (!payModal) return;
const { ids } = payModal;
setPaySubmitting(true);
if (ids.length > 1) setBatchLoading(true);
try {
const body = JSON.stringify({
...payload,
...(ids.length > 1 ? { ids } : {}),
});
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
await request('/admin/store-bills/batch-confirm', { method: 'POST', body });
}
message.success('已确认打款');
setPayModal(null);
setSelectedKeys([]);
reload();
} finally {
setPaySubmitting(false);
setBatchLoading(false);
}
}}
/>
<FinancePayProofModal
open={!!withdrawApproveId}
title="审核通过并标记已结算?"
hint="通过后将标记该提现为已结算,此操作不可撤销。"
okText="通过并结算"
confirmLoading={withdrawSubmitting}
onCancel={() => setWithdrawApproveId(null)}
onOk={async (payload) => {
if (!withdrawApproveId) return;
setWithdrawSubmitting(true);
try {
await request(`/admin/store-withdrawals/${withdrawApproveId}/approve`, {
method: 'POST',
body: JSON.stringify(payload),
});
message.success('已通过并标记已结算');
setWithdrawApproveId(null);
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
} finally {
setWithdrawSubmitting(false);
}
}}
/>
</div> </div>
); );
} }
@@ -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,70 @@ 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('已处理'); try {
void reload(); const res = await request<{ deleted?: boolean; softDisabled?: boolean }>(
}} `/admin/store-categories/${row.id}`,
> { method: 'DELETE' },
<Button type="link" size="small" danger></Button> );
</Popconfirm> if (res.softDisabled) {
message.success('该分类已有门店占用,已改为停用');
} else {
message.success('已删除');
}
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}}
>
<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"
+42 -19
View File
@@ -4,14 +4,17 @@ import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
STORE_LOG_CATEGORY_OPTIONS, STORE_LOG_CATEGORY_OPTIONS,
STORE_LOG_CATEGORY_LABELS,
resolveStoreLogCategory,
type StoreLogCategory, type StoreLogCategory,
} from '../lib/store-log'; } from '../lib/store-log';
import { request } from '../lib/api'; 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 { clientAppLabel, eventNameLabel, formatRef, storeEventCategoryLabel } from '../lib/display-labels';
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 +83,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}
@@ -98,20 +101,36 @@ export default function StoreLogsPage() {
{ {
title: '分类', dataIndex: 'category', width: 100, title: '分类', dataIndex: 'category', width: 100,
render: (v: StoreLogCategory | null, r) => ( render: (v: StoreLogCategory | null, r) => (
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag> <Tag>{storeEventCategoryLabel(r.eventName, v)}</Tag>
),
},
{
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);
}}
>
{eventNameLabel(v)}
</AdminPrimaryLink>
), ),
}, },
{ title: '事件', dataIndex: 'eventName', width: 160 },
{ {
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,
}, },
{ {
title: '关联', width: 120, title: '关联', width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), render: (_, r) => formatRef(r.refType, r.refId),
}, },
{ {
title: '摘要', ellipsis: true, title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson), render: (_, r) => summarizeExtra(r.extraJson),
}, },
{ {
@@ -127,12 +146,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 }))}
@@ -160,11 +183,11 @@ export default function StoreLogsPage() {
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item> <Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item> <Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item> <Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
<Form.Item name="eventName" label="事件"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item> <Form.Item name="eventName" label="事件编码"><Input allowClear style={{ width: 160 }} placeholder="事件编码" /></Form.Item>
<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 && (
@@ -174,12 +197,12 @@ export default function StoreLogsPage() {
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item> <Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item> <Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
<Descriptions.Item label="分类"> <Descriptions.Item label="分类">
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')} {storeEventCategoryLabel(String(detail.eventName ?? ''), detail.category ? String(detail.category) : null)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item> <Descriptions.Item label="事件">{eventNameLabel(String(detail.eventName ?? ''))}</Descriptions.Item>
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item> <Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item> <Descriptions.Item label="端">{clientAppLabel(detail.clientApp ? String(detail.clientApp) : null)}</Descriptions.Item>
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item> <Descriptions.Item label="关联">{formatRef(detail.refType ? String(detail.refType) : null, detail.refId ? String(detail.refId) : null)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
<Descriptions.Item label="参数"> <Descriptions.Item label="参数">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}> <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
+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();
@@ -1,95 +1,155 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { import {
Badge,
Button, Button,
Descriptions,
Drawer, Drawer,
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,
StorePackageChangeRequestDto, StorePackageChangeRequestDto,
StorePackageItemDto, StorePackageChangeStatus,
StorePackageViewDto, } from '@dukang/shared-types';
import {
STORE_INFO_CHANGE_STATUS_LABELS,
STORE_INFO_CHANGEABLE_FIELD_LABELS,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { STORE_PACKAGE_CHANGE_STATUS_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 { 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';
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) { const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
const name = String(pkg.name ?? '').trim(); PENDING: '待审核',
return name ? `name:${name}` : `idx:${index}`; APPROVED: '已通过',
} REJECTED: '已驳回',
};
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) { function fmtFieldValue(field: string, v: unknown): string {
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p])); if (v == null || String(v).trim() === '') return '(空)';
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p])); if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]); return String(v);
const rows: Array<{
key: string;
change: 'added' | 'removed' | 'changed' | 'unchanged';
live?: StorePackageViewDto;
proposed?: StorePackageItemDto;
}> = [];
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 ?? '');
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
}
} }
return rows; if (field === 'envPhotoUrls' && Array.isArray(v)) {
return `${v.length}`;
}
if (field === 'coverUrl') return '见对比图';
return String(v);
} }
const CHANGE_LABELS = { function InfoChangeImageDiff({
added: { text: '新增', color: 'green' }, field,
removed: { text: '删除', color: 'red' }, live,
changed: { text: '变更', color: 'orange' }, proposed,
unchanged: { text: '未变', color: 'default' }, imageSize = 72,
} as const; }: {
field: string;
live: unknown;
proposed: unknown;
imageSize?: number;
}) {
const liveUrls =
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)
: [];
export default function StorePackageAuditsPage() { return (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
</Typography.Text>
{liveUrls.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{liveUrls.map((url) => (
<Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
<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>
);
}
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 [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 = await request<Paginated<StorePackageChangeRequestDto>>( const [data, summary] = await Promise.all([
`/admin/store-package-audits?${qs}`, request<{ items: StoreInfoChangeRequestDto[]; total: number; page?: number }>(
); `/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);
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '加载失败'); message.error(e instanceof Error ? e.message : '加载失败');
} finally { } finally {
@@ -101,12 +161,19 @@ export default function StorePackageAuditsPage() {
void reload(1, status); void reload(1, status);
}, [status]); }, [status]);
useEffect(() => {
if (initialRequestId) void openDetail(initialRequestId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function openDetail(id: string) { async function openDetail(id: string) {
setDetailOpen(true); setDetailOpen(true);
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 : '加载详情失败');
@@ -118,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 },
@@ -126,78 +193,43 @@ export default function StorePackageAuditsPage() {
}); });
message.success(action === 'APPROVE' ? '已通过' : '已驳回'); message.success(action === 'APPROVE' ? '已通过' : '已驳回');
setDetailOpen(false); setDetailOpen(false);
notifyPackageAuditChanged();
void reload(page, status); void reload(page, status);
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '操作失败'); message.error(e instanceof Error ? e.message : '操作失败');
} }
} }
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : []; const columns: ColumnsType<StoreInfoChangeRequestDto> = [
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
{ {
title: '变更', title: '门店',
dataIndex: 'change', dataIndex: 'storeName',
width: 72, render: (_, row) => (
render: (v: keyof typeof CHANGE_LABELS) => { <AdminPrimaryLink onClick={() => void openDetail(row.id)}>
const meta = CHANGE_LABELS[v]; {row.storeName || row.storeId}
return <Tag color={meta.color}>{meta.text}</Tag>; </AdminPrimaryLink>
},
},
{
title: '当前线上',
render: (_, row) =>
row.live ? (
<div>
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
{row.live.usableTime ? (
<div><Typography.Text type="secondary">{row.live.usableTime}</Typography.Text></div>
) : null}
{row.live.otherNotes ? (
<div><Typography.Text type="secondary">{row.live.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
},
{
title: '申请变更',
render: (_, row) =>
row.proposed ? (
<div>
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
{row.proposed.usableTime ? (
<div><Typography.Text type="secondary">{row.proposed.usableTime}</Typography.Text></div>
) : null}
{row.proposed.otherNotes ? (
<div><Typography.Text type="secondary">{row.proposed.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
},
];
const columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '状态',
dataIndex: 'status',
render: (v: StorePackageChangeRequestDto['status']) => (
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
), ),
}, },
{ {
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)) },
{ {
@@ -205,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' ? (
<> <>
@@ -234,11 +266,22 @@ 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
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'} key={s || 'all'}
type={status === s ? 'primary' : 'default'}
onClick={() => setStatus(s)}
>
{s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
STORE_INFO_CHANGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button> </Button>
))} ))}
</Space> </Space>
@@ -256,55 +299,331 @@ export default function StorePackageAuditsPage() {
/> />
<Drawer <Drawer
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'} title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={720} 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 ? (
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
) : detail ? ( ) : detail ? (
<> <>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_PACKAGE_CHANGE_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}
<Typography.Paragraph type="secondary"> {detail.diffs && detail.diffs.length ? (
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0} <Descriptions column={1} bordered size="small">
</Typography.Paragraph> {detail.diffs.map((d) => (
<Table <Descriptions.Item
size="small" key={d.field}
rowKey="key" label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
columns={diffColumns} >
dataSource={diffRows} {d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
pagination={false} <InfoChangeImageDiff
/> field={d.field}
live={d.live}
proposed={d.proposed}
imageSize={infoFullscreen ? 160 : 72}
/>
) : (
<span>
<Typography.Text delete type="secondary">
{fmtFieldValue(d.field, d.live)}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>
{fmtFieldValue(d.field, d.proposed)}
</Typography.Text>
</span>
)}
</Descriptions.Item>
))}
</Descriptions>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</> </>
) : 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}
@@ -329,3 +648,4 @@ export default function StorePackageAuditsPage() {
</div> </div>
); );
} }
+38 -7
View File
@@ -1,14 +1,19 @@
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;
serviceScore: number; serviceScore: number;
envScore: number; envScore: number;
comment?: string | null;
createdAt: string; createdAt: string;
redeemNo: string; redeemNo: string;
redeemAmount: number; redeemAmount: number;
@@ -20,22 +25,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: '门店',
@@ -50,14 +63,21 @@ export default function StoreRatingsPage() {
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` }, { title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
{ title: '服务分', dataIndex: 'serviceScore', width: 80 }, { title: '服务分', dataIndex: 'serviceScore', width: 80 },
{ title: '环境分', dataIndex: 'envScore', width: 80 }, { title: '环境分', dataIndex: 'envScore', width: 80 },
{
title: '评语',
dataIndex: 'comment',
ellipsis: true,
render: (v: string | null) => v || '—',
},
{ 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 +86,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,11 @@ 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';
import { FinancePayProofModal, PaymentProofGallery } from '../components/FinancePayProof';
type Row = { type Row = {
id: string; id: string;
@@ -66,6 +71,8 @@ export default function StoreWithdrawalsPage() {
pendingCount: number; pendingCount: number;
overdueCount: number; overdueCount: number;
} | null>(null); } | null>(null);
const [approveId, setApproveId] = useState<string | null>(null);
const [approveSubmitting, setApproveSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -85,34 +92,7 @@ export default function StoreWithdrawalsPage() {
} }
function approve(id: string) { function approve(id: string) {
let paymentRef = ''; setApproveId(id);
Modal.confirm({
title: '审核通过并标记已结算?',
content: (
<Input
placeholder="打款凭证号(可选)"
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
),
okText: '通过并结算',
cancelText: '取消',
onOk: async () => {
await request(`/admin/store-withdrawals/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
});
message.success('已通过并标记已结算');
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
},
});
} }
function reject(id: string) { function reject(id: string) {
@@ -147,14 +127,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 +209,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 +280,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,
@@ -351,6 +332,9 @@ export default function StoreWithdrawalsPage() {
{detail.paymentRef ? ( {detail.paymentRef ? (
<Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item> <Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item>
) : null} ) : null}
<Descriptions.Item label="凭证照片">
<PaymentProofGallery urls={detail.paymentProofUrls} />
</Descriptions.Item>
<Descriptions.Item label="收款户名"> <Descriptions.Item label="收款户名">
{storeAccount?.bankAccountName || '—'} {storeAccount?.bankAccountName || '—'}
</Descriptions.Item> </Descriptions.Item>
@@ -389,6 +373,36 @@ export default function StoreWithdrawalsPage() {
</> </>
) : null} ) : null}
</Drawer> </Drawer>
<FinancePayProofModal
open={!!approveId}
title="审核通过并标记已结算?"
hint="通过后将标记该提现为已结算,此操作不可撤销。"
okText="通过并结算"
confirmLoading={approveSubmitting}
onCancel={() => setApproveId(null)}
onOk={async (payload) => {
if (!approveId) return;
setApproveSubmitting(true);
try {
await request(`/admin/store-withdrawals/${approveId}/approve`, {
method: 'POST',
body: JSON.stringify(payload),
});
message.success('已通过并标记已结算');
setApproveId(null);
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
} finally {
setApproveSubmitting(false);
}
}}
/>
</div> </div>
); );
} }
File diff suppressed because it is too large Load Diff
+436 -39
View File
@@ -2,8 +2,10 @@ import { useEffect, useState } from 'react';
import { import {
Button, Button,
Card, Card,
Checkbox,
Drawer, Drawer,
Form, Form,
Image,
Input, Input,
Modal, Modal,
Radio, Radio,
@@ -28,18 +30,29 @@ import {
DEV_PLAN_TASK_TYPE_LABELS, DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS, SUPPORT_TICKET_STATUS_LABELS,
SUPPORT_TICKET_TYPE_LABELS, SUPPORT_TICKET_TYPE_LABELS,
SUPPORT_TICKET_PRIORITY_LABELS,
mapSupportTicketTypeToDevPlanTask, mapSupportTicketTypeToDevPlanTask,
type BatchReviewPreviewItem, type BatchReviewPreviewItem,
type BatchReviewPreviewResponse, type BatchReviewPreviewResponse,
type DevPlanTaskTypeDto, type DevPlanTaskTypeDto,
type DevPlanVersionDto,
type SupportTicketDto, type SupportTicketDto,
type SupportTicketLinkedTaskDto, type SupportTicketLinkedTaskDto,
type SupportTicketPriorityDto,
type SupportTicketStatusDto, type SupportTicketStatusDto,
type SupportTicketTypeDto, type SupportTicketTypeDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api'; 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 { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
const STATUS_COLOR: Record<SupportTicketStatusDto, string> = { const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
PENDING_REVIEW: 'orange', PENDING_REVIEW: 'orange',
@@ -47,6 +60,7 @@ const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
DEVELOPING: 'blue', DEVELOPING: 'blue',
TESTING: 'purple', TESTING: 'purple',
PASSED: 'green', PASSED: 'green',
PUBLISHED: 'cyan',
}; };
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map( const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
@@ -57,6 +71,17 @@ const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTick
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }), (value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
); );
const PRIORITY_COLOR: Record<SupportTicketPriorityDto, string> = {
LOW: 'default',
NORMAL: 'blue',
HIGH: 'orange',
URGENT: 'red',
};
const PRIORITY_OPTIONS = (Object.keys(SUPPORT_TICKET_PRIORITY_LABELS) as SupportTicketPriorityDto[]).map(
(value) => ({ value, label: SUPPORT_TICKET_PRIORITY_LABELS[value] }),
);
const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map( const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map(
(v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }), (v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }),
); );
@@ -73,6 +98,7 @@ export default function SupportTicketsPage() {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.ticketType) qs.set('ticketType', filters.ticketType); if (filters.ticketType) qs.set('ticketType', filters.ticketType);
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.priority) qs.set('priority', filters.priority);
return qs; return qs;
}, },
[filters], [filters],
@@ -88,15 +114,45 @@ export default function SupportTicketsPage() {
const [batchPreviewOpen, setBatchPreviewOpen] = useState(false); const [batchPreviewOpen, setBatchPreviewOpen] = useState(false);
const [batchPreview, setBatchPreview] = useState<BatchReviewPreviewItem[]>([]); const [batchPreview, setBatchPreview] = useState<BatchReviewPreviewItem[]>([]);
const [batchConfirming, setBatchConfirming] = useState(false); const [batchConfirming, setBatchConfirming] = useState(false);
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
const [batchStatusSaving, setBatchStatusSaving] = useState(false);
const [batchCreateSaving, setBatchCreateSaving] = useState(false);
const [batchPublishOpen, setBatchPublishOpen] = useState(false);
const [batchPublishSaving, setBatchPublishSaving] = useState(false);
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
const [editOpen, setEditOpen] = useState(false);
const [editSaving, setEditSaving] = useState(false);
const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE'); const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE');
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [reviewForm] = Form.useForm<{ decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string }>(); const [reviewForm] = Form.useForm<{ decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string }>();
const [tasksForm] = Form.useForm<{ tasks: Array<{ content: string; type: DevPlanTaskTypeDto }> }>(); const [tasksForm] = Form.useForm<{
tasks: Array<{ content: string; type: DevPlanTaskTypeDto }>;
dispatchToWecom?: boolean;
dispatchSupplement?: string;
}>();
const [batchForm] = Form.useForm<{ items: BatchReviewPreviewItem[] }>(); const [batchForm] = Form.useForm<{ items: BatchReviewPreviewItem[] }>();
const [batchStatusForm] = Form.useForm<{
status: SupportTicketStatusDto;
rejectReason?: string;
note?: string;
}>();
const [batchPublishForm] = Form.useForm<{
versionId: string;
dispatchToWecom?: boolean;
dispatchSupplement?: string;
}>();
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
if (!batchPublishOpen) return;
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
.then((res) => setVersions(res.items ?? []))
.catch(() => setVersions([]));
}, [batchPublishOpen]);
useEffect(() => { useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
@@ -117,6 +173,8 @@ export default function SupportTicketsPage() {
title: values.title.trim(), title: values.title.trim(),
content: values.content?.trim() || undefined, content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
priority: values.priority,
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
}), }),
}); });
message.success('技术支持工单已创建,等待最高管理员评审'); message.success('技术支持工单已创建,等待最高管理员评审');
@@ -164,15 +222,150 @@ 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',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
}); });
setCreateTasksOpen(true); setCreateTasksOpen(true);
} }
function openEdit() {
if (!detail) return;
editForm.setFieldsValue({
ticketType: detail.ticketType,
title: detail.title,
content: detail.content || '',
remark: detail.remark || '',
priority: detail.priority ?? 'NORMAL',
attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''],
});
setEditOpen(true);
}
async function submitEdit() {
const values = await editForm.validateFields();
setEditSaving(true);
try {
await request(`/admin/support-tickets/${detail!.id}`, {
method: 'PATCH',
body: JSON.stringify({
ticketType: values.ticketType,
title: values.title.trim(),
content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined,
priority: values.priority,
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
}),
});
message.success('已保存');
setEditOpen(false);
await openDetail(String(detail!.id));
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setEditSaving(false);
}
}
async function submitBatchStatus() {
const values = await batchStatusForm.validateFields();
setBatchStatusSaving(true);
try {
const res = await request<{ successCount: number; failCount: number }>(
'/admin/support-tickets/batch-update-status',
{
method: 'POST',
body: JSON.stringify({
ticketIds: selectedRowKeys,
status: values.status,
rejectReason: values.rejectReason?.trim() || undefined,
note: values.note?.trim() || undefined,
}),
},
);
message.success(`成功 ${res.successCount} 条,失败 ${res.failCount}`);
setBatchStatusOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量更新失败');
} finally {
setBatchStatusSaving(false);
}
}
async function submitBatchCreateTasks() {
if (!selectedRowKeys.length) return;
setBatchCreateSaving(true);
try {
const res = await request<{ successCount: number; failCount: number }>(
'/admin/support-tickets/batch-create-tasks',
{
method: 'POST',
body: JSON.stringify({ ticketIds: selectedRowKeys }),
},
);
message.success(`已创建 ${res.successCount} 条任务,跳过/失败 ${res.failCount}`);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量创建任务失败');
} finally {
setBatchCreateSaving(false);
}
}
function openBatchPublish() {
batchPublishForm.setFieldsValue({
versionId: undefined,
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
});
setBatchPublishOpen(true);
}
async function submitBatchPublish() {
const values = await batchPublishForm.validateFields();
localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false');
setBatchPublishSaving(true);
try {
const res = await request<{
successCount: number;
failCount: number;
linkedTaskCount: number;
}>('/admin/support-tickets/batch-publish', {
method: 'POST',
body: JSON.stringify({
ticketIds: selectedRowKeys,
versionId: values.versionId,
dispatchToWecom: !!values.dispatchToWecom,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}),
});
message.success(
`已关联 ${res.linkedTaskCount} 条任务到版本,工单成功 ${res.successCount} 条,失败 ${res.failCount}`,
);
setBatchPublishOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量发布失败');
} finally {
setBatchPublishSaving(false);
}
}
async function submitCreateTasks() { async function submitCreateTasks() {
const values = await tasksForm.validateFields(); const values = await tasksForm.validateFields();
const note = reviewForm.getFieldValue('note') as string | undefined; const note = reviewForm.getFieldValue('note') as string | undefined;
localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false');
setActing(true); setActing(true);
try { try {
await request(`/admin/support-tickets/${detail!.id}/review`, { await request(`/admin/support-tickets/${detail!.id}/review`, {
@@ -180,10 +373,16 @@ 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,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}), }),
}); });
message.success('审批通过,已创建开发任务'); message.success(values.dispatchToWecom ? '审批通过,已创建任务并派发企微' : '审批通过,已创建开发任务');
setCreateTasksOpen(false); setCreateTasksOpen(false);
setDrawerOpen(false); setDrawerOpen(false);
reload(); reload();
@@ -275,7 +474,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: '类型',
@@ -283,15 +482,38 @@ export default function SupportTicketsPage() {
width: 90, width: 90,
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t, render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
}, },
{
title: '优先级',
dataIndex: 'priority',
width: 90,
render: (p: SupportTicketPriorityDto) => (
<Tag color={PRIORITY_COLOR[p] ?? 'default'}>
{SUPPORT_TICKET_PRIORITY_LABELS[p] ?? p ?? '普通'}
</Tag>
),
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 100,
render: (s: SupportTicketStatusDto) => ( render: (s: SupportTicketStatusDto, row) => (
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag> <Space size={4} direction="vertical" style={{ lineHeight: 1.2 }}>
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
{row.releasedVersionNo ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{row.releasedVersionNo}
</Typography.Text>
) : null}
</Space>
),
},
{
title: '标题',
dataIndex: 'title',
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(String(row.id))}>{v}</AdminPrimaryLink>
), ),
}, },
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '创建人', dataIndex: 'creatorName', width: 100 }, { title: '创建人', dataIndex: 'creatorName', width: 100 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -309,9 +531,12 @@ export default function SupportTicketsPage() {
if (!detail) return null; if (!detail) return null;
if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) { if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) {
return ( return (
<Button type="primary" loading={acting} onClick={openReview}> <Space>
<Button onClick={openEdit}></Button>
</Button> <Button type="primary" loading={acting} onClick={openReview}>
</Button>
</Space>
); );
} }
if (detail.status === 'DEVELOPING') { if (detail.status === 'DEVELOPING') {
@@ -331,30 +556,48 @@ export default function SupportTicketsPage() {
return null; return null;
})(); })();
const { columns, settingsButton, settingsModal } = useAdminListColumns('support-tickets', baseColumns, { page, pageSize });
return ( return (
<div> <div>
<div {settingsModal}
style={{ <AdminListHeader
display: 'flex', title="技术支持"
justifyContent: 'space-between', settings={settingsButton}
alignItems: 'center', actions={
marginBottom: 16, <>
}} {isSuperAdmin ? (
> <>
<Typography.Title level={4} style={{ margin: 0 }}> <Button
disabled={!selectedRowKeys.length}
</Typography.Title> loading={batchCreateSaving}
<Space> onClick={() => void submitBatchCreateTasks()}
{isSuperAdmin ? ( >
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
</Button>
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
</Button>
<Button
disabled={!selectedRowKeys.length}
onClick={() => {
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
setBatchStatusOpen(true);
}}
>
</Button>
</>
) : null}
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button> </Button>
) : null} </>
<Button type="primary" onClick={() => setCreateOpen(true)}> }
/>
</Button>
</Space>
</div>
<Form <Form
layout="inline" layout="inline"
@@ -370,6 +613,9 @@ export default function SupportTicketsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} /> <Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级">
<Select allowClear style={{ width: 120 }} options={PRIORITY_OPTIONS} />
</Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
</Button> </Button>
@@ -380,16 +626,12 @@ 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
? { ? {
selectedRowKeys, selectedRowKeys,
onChange: (keys, rows) => { onChange: (keys) => setSelectedRowKeys(keys as string[]),
const pending = rows.filter((r) => r.status === 'PENDING_REVIEW').map((r) => String(r.id));
setSelectedRowKeys(pending.length === rows.length ? (keys as string[]) : pending);
},
getCheckboxProps: (row) => ({ disabled: row.status !== 'PENDING_REVIEW' }),
} }
: undefined : undefined
} }
@@ -415,6 +657,9 @@ export default function SupportTicketsPage() {
{detail.ticketNo} {detail.ticketNo}
</Typography.Text> </Typography.Text>
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag> <Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
<Tag color={PRIORITY_COLOR[detail.priority ?? 'NORMAL']}>
{SUPPORT_TICKET_PRIORITY_LABELS[detail.priority ?? 'NORMAL']}
</Tag>
</> </>
)} )}
</Space> </Space>
@@ -445,6 +690,11 @@ export default function SupportTicketsPage() {
{detail.status === 'PASSED' && ( {detail.status === 'PASSED' && (
<Typography.Text type="success"></Typography.Text> <Typography.Text type="success"></Typography.Text>
)} )}
{detail.status === 'PUBLISHED' && (
<Typography.Text type="success">
{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''}
</Typography.Text>
)}
</Space> </Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(detail.createdAt)} {fmtTime(detail.createdAt)}
@@ -468,6 +718,7 @@ export default function SupportTicketsPage() {
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt }, { key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null }, { key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt }, { key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
{ key: 'PUBLISHED' as SupportTicketStatusDto, label: '已发布', time: detail.publishedAt },
] ]
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]), : [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]),
]; ];
@@ -612,6 +863,18 @@ export default function SupportTicketsPage() {
</div> </div>
</div> </div>
)} )}
{detail.attachmentUrls?.length ? (
<div style={{ marginTop: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
</Typography.Text>
<Space wrap style={{ marginTop: 8 }}>
{detail.attachmentUrls.map((url) => (
<Image key={url} src={url} width={80} height={80} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</div>
) : null}
</Card> </Card>
{/* 关联开发任务 */} {/* 关联开发任务 */}
@@ -661,10 +924,13 @@ export default function SupportTicketsPage() {
</Drawer> </Drawer>
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交"> <Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG' }}> <Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', priority: 'NORMAL', attachmentUrls: [''] }}>
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}> <Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}> <Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount /> <Input maxLength={128} showCount />
</Form.Item> </Form.Item>
@@ -674,6 +940,27 @@ export default function SupportTicketsPage() {
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount /> <Input.TextArea rows={2} maxLength={512} showCount />
</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 ? (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
@@ -725,6 +1012,116 @@ export default function SupportTicketsPage() {
</> </>
)} )}
</Form.List> </Form.List>
<Form.Item name="dispatchToWecom" valuePropName="checked" style={{ marginTop: 16 }}>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="dispatchSupplement" label="派发补充说明">
<Input.TextArea rows={2} placeholder={DEFAULT_DISPATCH_SUPPLEMENT} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑工单"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => void submitEdit()}
confirmLoading={editSaving}
destroyOnClose
width={640}
>
<Form form={editForm} layout="vertical">
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount />
</Form.Item>
<Form.Item name="content" label="详细说明">
<Input.TextArea rows={5} maxLength={4000} showCount />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</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 ? (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form>
</Modal>
<Modal
title="批量改状态"
open={batchStatusOpen}
onCancel={() => setBatchStatusOpen(false)}
onOk={() => void submitBatchStatus()}
confirmLoading={batchStatusSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary"> {selectedRowKeys.length} </Typography.Paragraph>
<Form form={batchStatusForm} layout="vertical">
<Form.Item name="status" label="目标状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS.filter((o) => o.value !== 'PENDING_REVIEW')} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.status !== cur.status}>
{({ getFieldValue }) =>
getFieldValue('status') === 'REJECTED' ? (
<Form.Item name="rejectReason" label="驳回理由" rules={[{ required: true }]}>
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title="一键发布"
open={batchPublishOpen}
onCancel={() => setBatchPublishOpen(false)}
onOk={() => void submitBatchPublish()}
confirmLoading={batchPublishSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary">
{selectedRowKeys.length}
</Typography.Paragraph>
<Form form={batchPublishForm} layout="vertical">
<Form.Item name="versionId" label="开发版本" rules={[{ required: true, message: '请选择开发版本' }]}>
<Select
placeholder="选择版本"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
<Form.Item name="dispatchToWecom" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="dispatchSupplement" label="派发补充说明">
<Input.TextArea rows={2} placeholder={DEFAULT_DISPATCH_SUPPLEMENT} />
</Form.Item>
</Form> </Form>
</Modal> </Modal>
+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() {
@@ -0,0 +1,708 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Card,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
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;
type PhoneRow = {
id: string;
phone: string;
note: string | null;
createdByHqId: string | null;
createdAt: string;
updatedAt: string;
};
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
type LinkedPayload = {
phone: PhoneRow;
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
};
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
{ value: 'user', label: 'C 端用户' },
{ value: 'store_account', label: '门店账号' },
{ value: 'partner', label: '合伙人' },
{ value: 'store', label: '门店' },
{ value: 'order', label: '订单' },
];
export default function TestWhitelistPage() {
const [mockSms, setMockSms] = useState(false);
const [mockWechat, setMockWechat] = useState(false);
const [mockPay, setMockPay] = useState(false);
const [mockLoading, setMockLoading] = useState(true);
const [mockSaving, setMockSaving] = useState(false);
const [phoneForm] = Form.useForm();
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
const [phonesLoading, setPhonesLoading] = useState(false);
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
const [addOpen, setAddOpen] = useState(false);
const [addForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
const [editForm] = Form.useForm();
const [migrating, setMigrating] = useState(false);
const [accountType, setAccountType] = useState<AccountType>('user');
const [accountPhone, setAccountPhone] = useState('');
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
const [accountsLoading, setAccountsLoading] = useState(false);
const [accountPage, setAccountPage] = useState(1);
const [accountPageSize, setAccountPageSize] = useState(20);
const [linkedOpen, setLinkedOpen] = useState(false);
const [linkedLoading, setLinkedLoading] = useState(false);
const [linked, setLinked] = useState<LinkedPayload | null>(null);
async function loadMockFlags() {
setMockLoading(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
} finally {
setMockLoading(false);
}
}
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
setMockSaving(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
{
method: 'PUT',
body: JSON.stringify(body),
},
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
message.success('已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
await loadMockFlags();
} finally {
setMockSaving(false);
}
}
const loadPhones = useCallback(async () => {
setPhonesLoading(true);
try {
const qs = new URLSearchParams({
page: String(phonePage),
pageSize: String(phonePageSize),
});
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
setPhones(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
} finally {
setPhonesLoading(false);
}
}, [phonePage, phonePageSize, phoneFilters]);
const loadAccounts = useCallback(async () => {
setAccountsLoading(true);
try {
const qs = new URLSearchParams({
type: accountType,
page: String(accountPage),
pageSize: String(accountPageSize),
});
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
const res = await request<Paginated<Record<string, unknown>>>(
`/admin/test-whitelist/accounts?${qs}`,
);
setAccounts(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载测试账号失败');
} finally {
setAccountsLoading(false);
}
}, [accountType, accountPage, accountPageSize, accountPhone]);
useEffect(() => {
void loadMockFlags();
}, []);
useEffect(() => {
void loadPhones();
}, [loadPhones]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
async function onAddPhone() {
const v = await addForm.validateFields();
try {
await request('/admin/test-whitelist/phones', {
method: 'POST',
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
});
message.success('已添加');
setAddOpen(false);
addForm.resetFields();
setPhonePage(1);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败');
}
}
async function onEditPhone() {
if (!editRow) return;
const v = await editForm.validateFields();
try {
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
method: 'PATCH',
body: JSON.stringify({ note: v.note ?? null }),
});
message.success('已更新');
setEditOpen(false);
setEditRow(null);
void loadPhones();
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败');
}
}
async function onDeletePhone(id: string) {
try {
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
message.success('已删除');
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function onMigrate() {
setMigrating(true);
try {
const res = await request<{ importedCandidates: number; added: number }>(
'/admin/test-whitelist/migrate-visibility',
{ method: 'POST' },
);
message.success(
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added}`,
);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败');
} finally {
setMigrating(false);
}
}
async function openLinked(row: PhoneRow) {
setLinkedOpen(true);
setLinkedLoading(true);
setLinked(null);
try {
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
setLinked(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载关联失败');
setLinkedOpen(false);
} finally {
setLinkedLoading(false);
}
}
const phoneColumns: ColumnsType<PhoneRow> = [
{
title: '手机号',
dataIndex: 'phone',
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '备注',
dataIndex: 'note',
render: (v) => v || '—',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: fmtTime,
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openLinked(row)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
</Button>
<Popconfirm
title="确认移出白名单?"
description="将同步清除该手机号关联账号的测试标记"
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void onDeletePhone(row.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
function accountColumns(): ColumnsType<Record<string, unknown>> {
if (accountType === 'user') {
return [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store_account') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'partner') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store') {
return [
{ title: '门店名', dataIndex: 'name', width: 160 },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{ title: '状态', dataIndex: 'status', width: 110 },
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => `¥${v}`,
},
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户手机',
width: 120,
render: (_, row) =>
(row.user as { phone?: string | null } | undefined)?.phone || '—',
},
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
const { columns, settingsButton, settingsModal } = useAdminListColumns('test-whitelist', phoneColumns, {
page: phonePage,
pageSize: phonePageSize,
});
return (
<div>
{settingsModal}
<AdminListHeader title="白名单管理" settings={settingsButton} />
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Mock =
</Typography.Text>
<Space wrap>
<Checkbox
checked={mockSms}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockWechat}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockPay}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
>
</Checkbox>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
{MOCK_KEYS.join(' / ')}
</Typography.Text>
</Card>
<Tabs
items={[
{
key: 'phones',
label: '手机号名单',
children: (
<>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
<Form
form={phoneForm}
layout="inline"
onFinish={(v) => {
setPhoneFilters({ phone: v.phone || undefined });
setPhonePage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊搜索" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Space>
<Button
onClick={() => {
Modal.confirm({
title: '从可见性白名单导入',
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
okText: '开始导入',
cancelText: '取消',
onOk: () => onMigrate(),
});
}}
loading={migrating}
>
</Button>
<Button type="primary" onClick={() => setAddOpen(true)}>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={phonesLoading}
columns={columns}
dataSource={phones?.items ?? []}
pagination={{
current: phonePage,
pageSize: phonePageSize,
total: phones?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPhonePage(p);
setPhonePageSize(ps);
},
}}
/>
</>
),
},
{
key: 'accounts',
label: '测试账号记录',
children: (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Select
style={{ width: 140 }}
value={accountType}
options={ACCOUNT_TYPE_OPTIONS}
onChange={(v: AccountType) => {
setAccountType(v);
setAccountPage(1);
}}
/>
<Input
allowClear
placeholder="按手机号筛选"
style={{ width: 160 }}
value={accountPhone}
onChange={(e) => setAccountPhone(e.target.value)}
onPressEnter={() => {
setAccountPage(1);
void loadAccounts();
}}
/>
<Button
type="primary"
onClick={() => {
if (accountPage !== 1) setAccountPage(1);
else void loadAccounts();
}}
>
</Button>
</Space>
<Table
rowKey="id"
loading={accountsLoading}
columns={accountColumns()}
dataSource={accounts?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: accountPage,
pageSize: accountPageSize,
total: accounts?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setAccountPage(p);
setAccountPageSize(ps);
},
}}
/>
</>
),
},
]}
/>
<Modal
title="添加白名单手机号"
open={addOpen}
onCancel={() => setAddOpen(false)}
onOk={() => void onAddPhone()}
destroyOnClose
>
<Form form={addForm} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
]}
>
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title={`编辑备注 · ${editRow?.phone ?? ''}`}
open={editOpen}
onCancel={() => {
setEditOpen(false);
setEditRow(null);
}}
onOk={() => void onEditPhone()}
destroyOnClose
>
<Form form={editForm} layout="vertical">
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Drawer
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
open={linkedOpen}
onClose={() => setLinkedOpen(false)}
width={560}
destroyOnClose
>
{linkedLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : linked ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
</Descriptions>
<div>
<Typography.Title level={5}>C {linked.users.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.users}
columns={[
{ title: '编号', dataIndex: 'userNo' },
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.storeAccounts.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.storeAccounts}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.partners.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.partners}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.stores.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.stores}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
</Space>
) : null}
</Drawer>
</div>
);
}

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