405 Commits

Author SHA1 Message Date
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 4d434c9c67 v3.5.4 规格主图、门店使用规则与收银台 logo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 17:36:53 +08:00
jacy 62e61a9e63 v3.5.4版本提交,修改api
CI / verify (pull_request) Has been cancelled
2026-08-21 16:22:53 +08:00
jacy a01217539c v3.5.4版本提交
CI / verify (pull_request) Has been cancelled
2026-08-21 15:48:15 +08:00
jacy 26334ed072 v3.5.3版本更新2
CI / verify (pull_request) Has been cancelled
2026-08-20 20:09:05 +08:00
jacy fc2e5b65de v3.5.3版本更新1
CI / verify (pull_request) Has been cancelled
2026-08-20 18:54:15 +08:00
jacy ee493823bf 增加音效逻辑
CI / verify (pull_request) Has been cancelled
2026-08-20 15:04:54 +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 6cf66c722c v3.5.2 大屏调整
CI / verify (pull_request) Has been cancelled
2026-08-20 11:58:15 +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 233ed0af3b v3.5.1 版本更新
CI / verify (pull_request) Has been cancelled
2026-08-19 15:54:51 +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
jacy aca3df4bf2 merge(dev): release v3.4.11 dev plan, wecom push and bot fixes
CI / verify (push) Has been cancelled
2026-08-04 22:41:49 +08:00
jacy 71f508e02b feat: multi-module iteration 2026-08-04 21:38:49 +08:00
jacy 9d96c73246 feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代 2026-08-04 21:32:13 +08:00
jacy fa0d928980 merge(dev): release v3.4.10
CI / verify (push) Has been cancelled
2026-08-03 22:54:23 +08:00
jacy c8ea5a3119 fix(mini-user): use Taro.request for analytics and prod API origin
Replace fetch with wx/Taro.request in client-logging for WeChat mini program; point production build to api.dukanghaoke.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 22:53:35 +08:00
jacy 6f427cb553 merge(dev): persona logging and Sentry system config
CI / verify (push) Has been cancelled
2026-08-03 22:31:27 +08:00
jacy 89fd333702 feat(analytics): persona logging upgrade and Sentry system config
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 22:30:02 +08:00
jacy ab10431001 feat(trade): connect WeChat refund API and callback flow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:45:33 +08:00
jacy e93e4b8f84 feat: audit diff, mock SMS 999888, env photos, proxy pay lock, mini-user UX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 20:47:28 +08:00
jacy d90847bad0 fix(deploy): staging C端 H5 根路径部署,去掉 /user 前缀
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 20:22:51 +08:00
jacy ffa753707b feat: 套餐折叠、小程序 staging API、首页去筛选、企微日志权限
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 20:17:52 +08:00
jacy ac2f9f4793 fix(shop): restore WithdrawPage import in h5-shop App
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 20:02:53 +08:00
jacy 3ba7b179e8 style(store): 统一门店套餐页 UI 与拓店校验弹窗
管理后台套餐 Tab 对齐 Ant Design 表单;合伙人/门店端套餐表单与基本信息页风格一致;拓店下一步校验增加弹窗提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 19:59:44 +08:00
jacy 4e7079c221 feat(admin): 侧边栏增加套餐审核入口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 19:32:07 +08:00
jacy 3c19b7dd97 feat(store): 门店套餐 v3.4.10 全端实现
新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 19:30:59 +08:00
jacy a7d55b7293 merge(dev): 走马灯滚出终点调整
CI / verify (push) Has been cancelled
2026-08-03 17:52:18 +08:00
jacy 7e3dc131ad fix(mini-user): 走马灯全文滚出视口后再多走 10px
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:52:10 +08:00
jacy f09e00e16f merge(dev): 门店核销走马灯优化
CI / verify (push) Has been cancelled
2026-08-03 16:34:36 +08:00
jacy ff462bca9a fix(mini-user): 门店核销走马灯小程序轮播与布局优化
单条从右向左位移至 -10px 后停留,位置移至门店详情上方;修复小程序 opacity/transform 不生效问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:34:28 +08:00
jacy aa0e140fec merge(dev): 核销方式标记与统计
CI / verify (push) Has been cancelled
2026-08-03 16:21:47 +08:00
jacy dc30932012 feat(redeem): 核销记录标记扫码/手机号并支持方式统计
落库 RedeemChannel,门店记录页增加统计按钮,HQ 可按方式筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:21:38 +08:00
jacy f8d8e2d6d5 merge(dev): 统一门店结算账单
CI / verify (push) Has been cancelled
2026-08-03 16:04:55 +08:00
jacy 54fca99208 feat(settlement): 统一门店账单列表合并 T+1 与手动提现
总部财务以门店账单为唯一入口;新增 store-settlements API,去掉独立提现审菜单并更新企微引导文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:04:45 +08:00
jacy 9859abba7e merge(dev): 全部门店可提现+企微提醒
CI / verify (push) Has been cancelled
2026-08-03 15:43:34 +08:00
jacy 26472f8678 fix(settlement): 全部门店可未出账提现并企微提醒
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:43:24 +08:00
jacy c89560e4f8 merge(dev): 门店手动提现 OPT-010
CI / verify (push) Has been cancelled
2026-08-03 15:32:17 +08:00
jacy f3353bbce5 feat(settlement): 门店未出账手动提现与总部审核(OPT-010)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:32:01 +08:00
jacy 666bcb8633 小程序端门店核销记录走马灯效果 2026-08-03 15:12:30 +08:00
jacy ffed560d43 merge(dev): 小程序 Intl 兜底
CI / verify (push) Has been cancelled
2026-08-03 14:54:02 +08:00
jacy a7a0a54688 fix(mini-user): 微信小程序补 Intl 兜底并去掉 toLocaleString
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:53:54 +08:00
jacy 5199b495e0 merge(dev): 门店核销走马灯兼容 H5/小程序
CI / verify (push) Has been cancelled
2026-08-03 14:48:41 +08:00
jacy 04fd4d50d0 fix(mini-user): 门店核销走马灯兼容 H5 与微信小程序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:48:28 +08:00
jacy 82b98aa4e3 merge(dev): 修复 Intl is not defined
CI / verify (push) Has been cancelled
2026-08-03 14:37:48 +08:00
jacy 2088a1ee19 fix(mini-user): 去掉 Intl,兼容微信小程序无 Intl 环境
门店详情走马灯与核销成功时间改为纯 Date 计算东八区格式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:37:44 +08:00
jacy 7dffd40f3f merge(dev): 走马灯 JS 实现
CI / verify (push) Has been cancelled
2026-08-03 14:33:35 +08:00
jacy 021b9e0329 fix(mini-user): 门店核销走马灯改用 JS 位移
微信端 CSS max-content/百分比 transform 不可靠;改为测量宽度后 translateX 滚动,并上移到店名下方。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:33:31 +08:00
jacy 0304201e80 merge(dev): 发版默认生产约定
CI / verify (push) Has been cancelled
2026-08-03 14:29:55 +08:00
jacy fd05621660 chore(cursor): 发版默认改为生产环境
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:29:53 +08:00
jacy dd4362c397 merge(dev): 门店详情修复
CI / verify (push) Has been cancelled
2026-08-03 14:29:03 +08:00
jacy 2ebdb08a2f fix(mini-user): 门店详情加载、标题居中与走马灯展示
修复仅用 useDidShow 导致一直加载中;顶栏标题左右等宽居中;走马灯改用 View 双份文本动画。发版默认改生产。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:28:59 +08:00
jacy 87515decb3 merge(dev): 核销时间格式与门店走马灯
CI / verify (push) Has been cancelled
2026-08-03 14:20:36 +08:00
jacy 4a5b9eaffa feat(store): 门店详情核销记录走马灯
新增 GET /stores/:id/recent-redeems,C 端在门店详情区上方循环展示脱敏核销动态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:16:06 +08:00
jacy e06d9f4711 小程序端日期时间显示格式 2026-08-03 14:03:45 +08:00
jacy 28ef916a22 merge(dev): 门店可见白名单
CI / verify (push) Has been cancelled
2026-08-03 13:58:47 +08:00
jacy fa3484041e feat(store): 门店可见白名单(对齐商品)
HQ 可配置 visibilityWhitelistEnabled + 手机号;C 端公开门店列表/详情按登录手机号过滤;登录态变化后小程序重新拉门店列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:58:35 +08:00
jacy 06cbcdeb9c 首页商品:对齐门店「当次会话」——首次进入 / 城市变化 / 登录态变化 / 下拉刷新才拉列表,切 Tab 不再重复请求;退出登录会清缓存。
核销成功时间:按 Asia/Shanghai 格式化为「2026年8月3日 13点45分」。
核销码编号:单行省略显示,双击复制(有「双击复制」提示)。
2026-08-03 13:51:29 +08:00
jacy ebd8c07147 数量加减:同城/跨城/现场取货确认页加减号不再置灰;点减到低于起购时 toast,数量仍可降到 1(不能到 0)。
支付后返回:支付成功进详情带 from=pay;返回一律 switchTab 首页(失败则 reLaunch),避免栈只有一页时 navigateBack 退出小程序。订单列表返回同样加固。
2026-08-03 13:22:12 +08:00
jacy 39e38aac7e merge(dev): mini-user eight fixes and catalog whitelist
CI / verify (push) Has been cancelled
2026-08-03 13:12:40 +08:00
jacy 87c4af7364 客服电话 → 13203801799(CUSTOMER_SERVICE_PHONE,协议文案走常量自动同步)
权益页去掉分享按钮,菜单分享保留
支付成功改 reLaunch 清栈;订单列表返回首页
减数量低于起购时 toast「同城/跨城/现场提货至少购买 N 瓶」
核销成功可评星并 POST /redeem/ratings;HQ 新增「门店评价」页 /store-ratings
成功页「回到首页」改用 .redeem-cancel-btn,不再撑破布局
历史记录改为核销流水:GET /redeem/records;生产库已有 2 笔(用户 13073729990)可验证。权益明细字段映射也一并修了
我的页头像提高点击层级,避免被卡片遮挡
2026-08-03 13:09:59 +08:00
jacy 9f67221457 商品白名单 2026-08-03 12:53:39 +08:00
jacy 23587418c1 门店端调用相机功能提示修改 2026-08-03 12:37:01 +08:00
jacy daf534b4db merge(dev): skip WeCom audit client-error noise
CI / verify (push) Has been cancelled
2026-08-02 21:52:18 +08:00
jacy 0621ac18fb fix(ops): skip WeCom alerts for mini-program audit navigate noise
Filter anonymous mini-app page-not-found client errors (often WeChat review bots) from webhook while still logging and persisting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:13 +08:00
jacy 442f1ea842 该商品不支持跨城配送,请更换为开城城市内的收货地址 2026-08-02 21:41:50 +08:00
jacy 8e25e23e81 商品只支持现场提货的功能修改 2026-08-02 21:39:22 +08:00
jacy 118993a01f merge(dev): deploy default DB sync
CI / verify (push) Has been cancelled
2026-08-02 18:12:37 +08:00
jacy a017a27b60 fix(deploy): run DB sync by default when release args omitted
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 18:12:35 +08:00
jacy 8f361981bf merge(dev): product fulfillment flags and auto SKU
CI / verify (push) Has been cancelled
2026-08-02 18:10:31 +08:00
jacy 847a3f0dc3 feat(catalog): add product fulfillment flags and auto SKU
Enable online/cross-city/on-site purchase switches with trade gates, HQ and proxy UI, and server-generated DK SKUs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 18:10:16 +08:00
jacy 49a5c057db 小程序商品列表上下滚动 2026-08-02 17:54:42 +08:00
jacy 4bb6ae20cd merge(dev): fix partner image picker after cancel
CI / verify (push) Has been cancelled
2026-08-02 17:11:31 +08:00
jacy 5deffbdbfb fix(h5-partner): allow reopening WeChat image picker after cancel
Avoid resetting JSSDK on each pick and unlock cancel paths so camera/album can be opened again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 17:11:19 +08:00
jacy ad757e9ba8 merge(dev): restore camera and fix mine benefit-detail back
CI / verify (push) Has been cancelled
2026-08-02 17:00:58 +08:00
jacy 07fbb74485 小程序端“我的明细”退回错误bug修复 2026-08-02 16:46:43 +08:00
jacy ca6fa2244b 合伙人端和门店端微信SDK调用相机复原 2026-08-02 16:34:56 +08:00
jacy e63b57db19 merge(dev): client error reporting
CI / verify (push) Has been cancelled
2026-08-02 16:27:20 +08:00
jacy 205c1110c2 feat(ops): add client error reporting API and frontend hooks
Collect mini-user/shop/partner JS errors via POST /common/client-errors, persist logs, and push fatal/error to WeCom.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 16:27:08 +08:00
jacy fb4432b307 merge(dev): WeCom webhook ops alerts
CI / verify (push) Has been cancelled
2026-08-02 16:02:35 +08:00
jacy 3328c52cb4 feat(ops): WeCom webhook alerts for pay/redeem anomalies
Add outbound group-bot alerts, rate/amount rules, stuck-order cron, HQ test button, and health db/redis checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 16:01:30 +08:00
jacy ab44515e8b merge(dev_jacy): on-site pickup COMPLETED on pay
CI / verify (push) Has been cancelled
2026-08-02 13:42:17 +08:00
jacy cb6ecaaba6 fix(trade): on-site pickup pay success marks order COMPLETED 2026-08-02 13:42:15 +08:00
jacy 78365d648c merge(dev_jacy): proxy-order real pay
CI / verify (push) Has been cancelled
2026-08-02 13:28:16 +08:00
jacy 4ba51c502b fix(proxy-order): remove mock pay button, use real WeChat pay 2026-08-02 13:28:09 +08:00
jacy e9eddbb26e merge(dev_jacy): shop WeChat unbound tip
CI / verify (push) Has been cancelled
2026-08-02 13:15:50 +08:00
jacy ab3b230d01 fix(h5-shop): tip unbound WeChat login to use phone first 2026-08-02 13:15:47 +08:00
jacy c00ca3620d merge(dev): partner dropdown label company-name
CI / verify (push) Has been cancelled
2026-08-02 13:07:41 +08:00
jacy 45d3d8164d fix(admin-web): show company-name in store create partner dropdown 2026-08-02 13:07:09 +08:00
jacy defdcb409f fix(deploy): force staging=dev and production=main regardless of deploy.env 2026-08-02 12:55:40 +08:00
jacy 4d76ee6d0e merge(dev): WeChat login restore, proxy pay, staging SSL verify
CI / verify (push) Has been cancelled
2026-08-02 12:53:53 +08:00
jacy bdc9dbde8d fix(h5-partner): make WeChat login click feedback and OAuth start reliably 2026-08-02 12:42:55 +08:00
jacy 3fc2f23e10 feat(h5-auth): restore partner and shop WeChat login
Re-enable one-tap WeChat login, post-SMS OAuth bind, and quick login gates so partner/shop openId flows work again on staging.
2026-08-02 11:59:23 +08:00
jacy 09e732bfa3 微信JSSDK对接,代下单支付 2026-08-02 11:49:35 +08:00
jacy 50ba6e9c56 概览页增加表格统计 2026-08-02 11:16:51 +08:00
jacy 6a23e79f4c chore(deploy): add same-host staging stack next to production
dev deploys to /opt/dukang-staging (819x, *-test domains); main deploys to production. Staging uses full Mock with shared WeChat app ids.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 11:37:18 +08:00
jacy 2dbe665bdc Merge pull request '合并到main 创建8.1最后一波修改,后面测试和正式就会分离' (#11) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/11
2026-07-31 11:27:55 +08:00
jacy 8d6c0f9ac0 feat(ops): partner proxy order list and HQ order delete
CI / verify (pull_request) Has been cancelled
Add partner-facing proxy order list/detail; unlock SUPER_ADMIN order delete with cascade cleanup and type filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:10:39 +08:00
jacy 51f2b5fbfa fix(trade): simplify proxy order and respect product whitelist
Drop SMS verification for HQ/partner proxy orders; keep form draft across product picker; hide whitelist-only products from partner catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 09:43:16 +08:00
jacy 2cd4e25682 feat(ops): add HQ admin proxy order and mini-user store session fixes
Align HQ orders page with partner dual-SMS offline proxy flow; improve mini-user stores session and WeChat confirm-receive handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 09:18:19 +08:00
jacy 1a0afb6d39 feat(trade): WeChat confirm-receive component + admin status log columns
Open weappOrderConfirm in mini-user so users confirm in-app instead of
service notice; verify via get_order before completing. Show operator/remark
on admin order status timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:48:44 +08:00
jacy 05dffbfa15 fix(wechat): use getStableAccessToken to avoid 40001
Switch mini/OA tokens to stable_token and retry shipping upload once on invalid credential.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:04:02 +08:00
jacy a0a6c14758 feat(trade): WeChat trade-manage push + delay shipping upload
Receive trade_manage_order_settlement to sync confirm-receive/settle;
wait ~65s and retry on 10060001 before upload_shipping_info.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:37:42 +08:00
jacy ed7fd4e013 feat(catalog): product visibility whitelist by phone
Admin can limit ON_SALE products to test phones; C-end filters by user phone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:08:57 +08:00
jacy bf9cd1bffd feat(trade): upload WeChat mini program shipping info
Hook SHIPPING and on-site pickup to upload_shipping_info for settlement unlock.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:35:21 +08:00
jacy ca621c9db8 fix(partner): restyle map picker header actions
Title centered; wide confirm on left, close on right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:08:35 +08:00
jacy 507a21a318 fix(partner): hide map pick coords from UI
Keep lat/lng in form state for submit; remove pending coords panel and toasts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:04:41 +08:00
jacy f396cb1fef feat(partner): map pick UX and Tencent LBS SN signature
Hide locate on store forms; restore optimized locpicker overlay.
Add TENCENT_LBS_SECRET_KEY and server-side sig for WebServiceAPI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 12:56:51 +08:00
jacy 2b85f63e2b 用户小程序手机号
门店闪烁逻辑
2026-07-29 12:30:03 +08:00
jacy e742f1e88b fix(mini-user): locate before store list fetch; prefer masked phone on mine
Resolve city once then fetch stores once; skip refetch when city unchanged. Show masked phone under nickname when available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:56:40 +08:00
jacy 2ccadce34a feat(mini-user): show masked phone under nickname on mine page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:31:02 +08:00
jacy b0ff254cd8 fix(partner): use server LBS search instead of locpicker iframe
Avoid Tencent locpicker _listTap crash when selecting POI in WeChat WebView.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:31:02 +08:00
jacy c3362851ff fix(partner): restore Tencent locpicker iframe for store map pick
Partner H5 store create/edit uses locpicker again; expose LBS key for iframe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:20:22 +08:00
jacy 804054df33 feat(mini-user): replace mine page icons with asset PNGs
Use icons under assets/icons for order shortcuts and common services.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:20:22 +08:00
jacy 1bc3bec977 Merge pull request 'Dev Main by v3.4 测试数据删除' (#10) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/10
2026-07-28 17:19:21 +08:00
jacy 309b180087 feat(admin): link city-partner store count; fix benefitUsageRule null string
CI / verify (pull_request) Has been cancelled
City partner storeCount jumps to /stores?partnerId=; normalize empty/null text so usage rule is not saved as literal "null.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 16:03:56 +08:00
jacy 118d57d710 feat(admin): delete store staff; city store count links to filtered list
CI / verify (pull_request) Has been cancelled
HQ can remove store sub-accounts from primary account detail; cities table store count jumps to /stores?cityId=.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:43:09 +08:00
jacy 0cb2b2cebb feat(admin): store detail edit with login phone sync; tighten HQ UI
CI / verify (pull_request) Has been cancelled
HQ store update now syncs store_account.phone for shop login; expand editable store fields. System version only for SUPER_ADMIN; permissions checklist layout polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:10:14 +08:00
jacy daeb24bbeb feat(admin): gate user/order/city delete behind assignable HQ permissions
CI / verify (pull_request) Has been cancelled
Add users_delete, orders_delete, cities_delete; default HQ (incl. super admin) has none until explicitly granted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:01:18 +08:00
jacy f4766d32ff fix(admin): make partner company/address/district optional on create UI
CI / verify (pull_request) Has been cancelled
Remove required asterisks on CityPartnersPage create form (main entry).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:44:19 +08:00
jacy b920c894b9 feat(admin): city delete with impact preview; optional partner fields
CI / verify (pull_request) Has been cancelled
Allow deleting open cities after listing partners/stores; company name, address, and districts optional on partner create.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:33:12 +08:00
jacy 37038a7591 fix(api): surface LLM network errors for WeCom AI replies
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:59:49 +08:00
jacy 944306e45c fix(admin,partner): replace Tencent iframe picker with server LBS search
CI / verify (pull_request) Has been cancelled
Avoid locpicker formatted_addresses crash; proxy suggest/nearby/reverse via /common/lbs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:56:50 +08:00
jacy 9ed0c24d11 fix(admin,partner): harden Tencent locpicker coords and confirm flow
CI / verify (pull_request) Has been cancelled
Accept latlng-only postMessage, confirm before apply, and read LBS key per request.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:47:08 +08:00
jacy 50349dd8e9 fix(mini-user): center qualification image on translucent mask
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:41:13 +08:00
jacy e2fd28a35b fix(partner): drop store-create SMS verify; keep qualification aspect ratio
CI / verify (pull_request) Has been cancelled
Partner H5 no longer requires store phone SMS on create; qualification overlay uses height auto with widthFix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:34:31 +08:00
jacy 15c35c49de 分享功能和资质文件展示 2026-07-28 00:31:31 +08:00
jacy a49d8d99b6 fix(partner): drop pickup store and reorder promo on proxy order
CI / verify (pull_request) Has been cancelled
On-site pickup no longer requires a store; move optional promo above fulfillment mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:19:52 +08:00
jacy aa9d689c76 fix(partner): guard null region on proxy order preview
CI / verify (pull_request) Has been cancelled
Avoid crash when province/city is not selected yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:10:47 +08:00
jacy b59d4484ec feat(partner): complete proxy order with dual SMS and pickup
CI / verify (pull_request) Has been cancelled
Add partner/customer SMS confirm, address auto-receive or on-site
pickup, proxy placer fields, PARTNER_PROXY user source, and C-end badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:04:59 +08:00
jacy cd23119470 feat(mini-user): enable share on home/benefit and OSS qualification
CI / verify (pull_request) Has been cancelled
Wire WeChat share on home and benefit tabs; serve mine qualification
from OSS; mask phones on admin users and wechat bindings pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:41:29 +08:00
jacy 18ab639669 fix(store): use line comment for benefitUsageRule in Prisma schema
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:25:45 +08:00
jacy fc03905777 feat(store): benefit coupon usage rules and show env photos on store detail
CI / verify (pull_request) Has been cancelled
Add benefitUsageRule on store create/edit (HQ + partner) and C-end detail between intro and env gallery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:25:18 +08:00
jacy ea8152ebf3 fix(deploy): use scp -P for sync-api-env port option
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:20:43 +08:00
jacy 78973e3fbf fix(api): seed empty system settings from env including Tencent LBS key
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:19:54 +08:00
jacy 317f910f3c feat(admin,partner): Tencent map location picker and expose LBS key
Store create/edit on HQ and partner can pick coords via Tencent locpicker; client-config exposes TENCENT_LBS_KEY; seed empty system settings from env.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:19:40 +08:00
jacy d36fe13bcd fix(admin,mini-user): store lat/lng edit with Baidu map, center pickup qty hint
CI / verify (pull_request) Has been cancelled
HQ store detail can set coordinates and open Baidu Map search by name/address; admin update API persists lat/lng.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:53:13 +08:00
jacy 15b878f2a4 fix(admin,mini-user): product on-sale tag, store coords inputs, home UX
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:40:57 +08:00
jacy 09eda9a11e H5端开店增加获取当前店铺位置 2026-07-27 22:37:15 +08:00
jacy 23c0cc7a5f 小程序优化,商铺定位等 2026-07-27 22:33:00 +08:00
jacy 1725b9e4b4 小程序加上公司资质
CI / verify (pull_request) Has been cancelled
2026-07-26 13:31:21 +08:00
jacy cc9933d1ce feat(admin): require business hours when creating stores
CI / verify (pull_request) Has been cancelled
HQ create-store wizard now collects 1-2 hour segments and avgPrice, and API validates them on create/update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:19:58 +08:00
jacy b810cabc6d fix(partner): restore clearAllStoreDrafts export
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:18:06 +08:00
jacy 25aee745cc fix(partner): export normalizeStringArray for store detail
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:17:24 +08:00
jacy 097c7dec4b fix(shop): resolve duplicate open binding on home page
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:17:03 +08:00
jacy 92cf51ba3d feat(store): dual business hours, avg price, and status lock
CI / verify (pull_request) Has been cancelled
Support 1-2 hour segments and optional avgPrice; show bank settlement on HQ store detail; enforce pickup min 2 bottles; Chinese order statuses; block shop reopen after permanent close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:16:22 +08:00
jacy 94d2a88581 chore(settlement): add finance bills mock seed script
CI / verify (pull_request) Has been cancelled
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:08:01 +08:00
jacy da21cd3daa 系统判断配送限制 2026-07-26 09:59:38 +08:00
jacy 19cb312465 物流对账单 2026-07-26 09:51:33 +08:00
jacy 2b7ef65cce feat(admin): LLM config, knowledge base, and WeCom AI binding
CI / verify (pull_request) Has been cancelled
Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:31:22 +08:00
jacy 745c192693 知识库
CI / verify (pull_request) Has been cancelled
2026-07-26 08:37:11 +08:00
jacy 34532dc9bf 微信企业机器人创建 2026-07-26 08:16:56 +08:00
jacy f053908acb 企业知识库搭建
CI / verify (pull_request) Has been cancelled
2026-07-26 07:45:08 +08:00
jacy 959e0b66cb 技术支持工单
CI / verify (pull_request) Has been cancelled
2026-07-26 07:31:49 +08:00
jacy ac1e0a9f13 feat(admin): require store category on HQ store create
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 07:13:56 +08:00
jacy 2a208d5dea 小程序端界面修改
CI / verify (pull_request) Has been cancelled
2026-07-23 15:23:23 +08:00
jacy 82c447b193 工单和发票
酒厂银行账户
2026-07-23 14:51:20 +08:00
jacy 75765bf9d4 后端修改权限和折叠功能
CI / verify (pull_request) Has been cancelled
2026-07-23 14:38:52 +08:00
jacy 6479365d6a 小程序页面优化
CI / verify (pull_request) Has been cancelled
2026-07-23 14:03:22 +08:00
jacy d11757c854 fix(admin): improve promo layout and system settings save UX
CI / verify (pull_request) Has been cancelled
Fix mini home media form persistence; float save with unsaved leave prompt; promo detail QR on the right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 13:54:10 +08:00
jacy 479de04f7c feat(mini-user): add configurable home banner and footer
CI / verify (pull_request) Has been cancelled
HQ system settings WeChat mini config with OSS swiper/footer uploads; client-config exposes miniHome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:15:15 +08:00
jacy cebeefdb22 feat(trade): add on-site pickup order flow
CI / verify (pull_request) Has been cancelled
Product flag, ON_SITE_PICKUP delivery, mini confirm/receive pages; pay skips warehouse auto-advance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:17:59 +08:00
jacy e93e4a7721 推广码后端页面优化 2026-07-23 00:43:49 +08:00
jacy cd6b82abd8 小程序端核销权益输入框限制
CI / verify (pull_request) Has been cancelled
2026-07-23 00:32:09 +08:00
jacy 047ffd7b16 chore(admin): label promo QR as mini-program code
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:21:55 +08:00
jacy bca1d8afea feat(promo): generate WeChat mini-program codes on create
Use getwxacodeunlimit with activity id as scene, upload PNG to OSS qrcode/, and show in HQ admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:21:10 +08:00
jacy 0418370c31 小程序端核销权益券金额输入限制
CI / verify (pull_request) Has been cancelled
现场取货功能
2026-07-23 00:03:21 +08:00
jacy cd206d6baa feat(ops): show redeem records on benefit coupon detail
CI / verify (pull_request) Has been cancelled
Reuse the same coupon redeem trace as order detail (primary/secondary, allocations).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:52:48 +08:00
jacy aeb72154a7 开城合伙人页与城市详情内合伙人 Tab 已同步 2026-07-22 23:43:16 +08:00
jacy e0d3c840a2 feat(ops): show order redeem records including FIFO secondary coupons
CI / verify (pull_request) Has been cancelled
Persist redeem allocations and surface them on HQ order detail with backfill for history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:38:51 +08:00
jacy f2303f7fb8 核销金额:最低 0.01 元;输入最多两位小数;低于该值会 toast 提示,无法生成核销码。
底部提示:左右留白 + 内边距,浅底圆角块,文案改为最低 0.01、余额上限、3 分钟到店出示。
新增地址:进入页会尝试定位,自动选中当前省市区(失败则保留默认郑州);编辑地址不重定位。
2026-07-22 23:11:34 +08:00
jacy fb530e2ff5 城市合伙人县区不做限制 2026-07-22 23:06:51 +08:00
jacy 8c4419e04e fix(admin): city-scoped district pick and clearer overlap errors
CI / verify (pull_request) Has been cancelled
Region partners only select districts under the chosen city; overlap errors name the occupying partner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:14:54 +08:00
jacy 85f2c156b0 fix(admin): prevent false partner commission cap errors
CI / verify (pull_request) Has been cancelled
Coerce city maxPartnerCommissionRate to Number so Decimal JSON strings do not break sum checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:03:32 +08:00
jacy addfcfb0b1 小程序页面优化(门店) 2026-07-22 17:56:25 +08:00
jacy b392c28787 feat(settlement): redesign factory/partner/store statement bills
CI / verify (pull_request) Has been cancelled
Add WineryBill/StoreBill tables, partner review-send-confirm flow, daily/monthly cron, and admin multi-select confirm with status filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 17:25:49 +08:00
jacy b534a569f8 小程序页面优化
CI / verify (pull_request) Has been cancelled
2026-07-22 15:12:19 +08:00
jacy b0f565103f 用户端修改:付款按钮,显示单价,订单的图片 2026-07-22 13:02:52 +08:00
jacy 5e33dd9847 开店第三页银行卡号:只允许数字,最多 19 位;校验仍为 16–19 位。
门店详情 / 开店页:去掉顶栏返回与标题空白,内容上顶,顶部约 12px,与「门店管理」一致。
开店第一页底部:在「下一步」左侧加了「返回」(回门店列表);第 2、3 步仍是「上一步」。
2026-07-22 12:44:12 +08:00
jacy 9a550cbaaf feat(store): add two-level store categories for admin and partner open-store
CI / verify (pull_request) Has been cancelled
Admin CRUD under stores menu; partner picks leaf category on create.
Default sync only inserts missing rows and never updates existing ones.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 12:10:02 +08:00
jacy 823e439101 开门营业成功后提示「开店成功」
关闭门店改为自定义确认框,不再用 window.confirm,不会再带出页面域名
2026-07-22 11:50:05 +08:00
jacy 6c299f1a1d 管理端门店详情(审核抽屉)加上「审核材料」区 2026-07-22 11:45:36 +08:00
jacy 7c9827875f 合伙人端开店的流程里加入开店营业时间,放在开店流程的第一页。 2026-07-22 11:30:25 +08:00
jacy ae7c63c08d feat(h5): add pull-to-refresh on user partner and shop pages
CI / verify (pull_request) Has been cancelled
Share PullToRefresh for partner/shop H5; enable Taro pull-down on mini-user lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:19:42 +08:00
jacy 270cebc79a fix(partner): move store-open phone verify to step 3
CI / verify (pull_request) Has been cancelled
Confirm phone and SMS after bank details, then submit directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 10:22:53 +08:00
jacy ba21cc89c6 Merge commit '56dfffd12647e1128ac4360aa9a4044361f0f7ab' into dev_jacy
CI / verify (pull_request) Has been cancelled
2026-07-22 09:05:51 +08:00
ljy 56dfffd126 fix:增加小程序客服 2026-07-21 22:24:53 +08:00
jacy bdf80e577b fix(mini-user): address review blockers
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:03:02 +08:00
jacy eb96b36d0b feat(redeem): streamline phone redemption confirmation
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:41:06 +08:00
jacy 36bec94639 fix(h5): temporarily disable WeChat camera flows
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:18:51 +08:00
jacy c11c7647b9 fix(h5): serve assets from dedicated domain roots
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:51:23 +08:00
jacy 83ed90ef67 feat(h5-auth): remember SMS sessions and simplify media access
CI / verify (pull_request) Has been cancelled
Hide WeChat login for shop and partner users, keep verified sessions for seven days, and allow JSSDK camera or album access without an OpenID binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:41:17 +08:00
jacy 73dbf6effb chore(deploy): stop tracking auto-release.env; feat(mini-user): avatar profile sheet
CI / verify (pull_request) Has been cancelled
Remove secrets file from the index (keep local). Mine page can choose avatar and nickname via WeChat components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:31:07 +08:00
jacy b8adfe98a3 ignore 2026-07-21 10:19:09 +08:00
jacy 6c6fb10490 fix(deploy): correct APP_ROOT to /opt/dukang
CI / verify (pull_request) Has been cancelled
Server path is /opt/dukang; stale /opt/dukang-haoke broke PM2 cwd for mini-user H5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:10:35 +08:00
jacy 935ab0d1d4 微信小程序审核不通过修改
CI / verify (pull_request) Has been cancelled
2026-07-21 09:32:06 +08:00
jacy 350a086a73 微信小程序端登录和头像调整 2026-07-21 08:59:44 +08:00
jacy c5f526d51d 我的页面中的头像 2026-07-21 08:10:52 +08:00
jacy e1f5130c18 页面顶部空白调整 2026-07-21 08:07:07 +08:00
jacy bc0b0cafd6 微信小程序支付遇到问题,因为没有 WX_MCH_PRIVATE_KEY 2026-07-21 07:59:57 +08:00
jacy ade68972a9 微信小程序端修改config服务器地址配置 2026-07-21 07:35:39 +08:00
jacy 47b39e3ba0 迁移的一些脚本文件 2026-07-21 00:01:50 +08:00
jacy cf73bbae23 迁移的一些脚本文件 2026-07-21 00:01:45 +08:00
jacy d1b207422c fix(api): add TICKET_COLLAB to EventType enum
CI / verify (pull_request) Has been cancelled
Unblock nest build for after-sale ticket collaboration events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 21:41:44 +08:00
jacy 4a29d8297c Merge commit '792b543ba88acda4186bd823461ef1bb76d12f5b' into dev_jacy
CI / verify (pull_request) Has been cancelled
2026-07-20 21:37:55 +08:00
ljy 792b543ba8 fix;提交、 2026-07-19 22:16:55 +08:00
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
jacy 76270b20bf Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev
CI / verify (pull_request) Has been cancelled
2026-07-17 10:43:19 +08:00
jacy c2914c37e5 fix(partner): grant store staff open/close and media permissions by default
CI / verify (pull_request) Has been cancelled
Default new sub-accounts to store:create+store:manage, backfill empty permissions on /partner/me, and treat legacy store staff as allowed to mutate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:43:15 +08:00
jacy 9f9b7cb2bd merge(dev_jacy): partner store manage and env photo dedupe 2026-07-17 10:36:31 +08:00
jacy de396442a4 feat(partner): let staff manage stores and fix env photo dupes
CI / verify (pull_request) Has been cancelled
Allow store:create/manage sub-accounts to edit, open/close, and re-upload media; dedupe ENV photos on write/read and replace via media API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:36:29 +08:00
jacy 02efe6fa14 merge(dev_jacy): fix login agreement visibility 2026-07-17 10:20:36 +08:00
jacy 14b867a3a5 fix(auth): show user agreement before login actions
CI / verify (pull_request) Has been cancelled
Quick login required agreement without a checkbox; move the consent row above CTAs across partner/shop/user/mini login pages so it stays visible on short screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:20:32 +08:00
jacy f750fea667 merge(dev_jacy): skip unauthorized partner home APIs 2026-07-17 10:13:45 +08:00
jacy 55e91158b3 fix(partner): skip unauthorized home APIs for sub-accounts
CI / verify (pull_request) Has been cancelled
Gate dashboard/stores/orders by permission and move dashboard auth to method level so leaderboard stays open without toasting 403s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:13:43 +08:00
jacy 19cb13d26c merge(dev_jacy): partner sub-account team leaderboard 2026-07-17 10:02:44 +08:00
jacy 04253b9120 feat(partner): let sub-accounts view team leaderboard
CI / verify (pull_request) Has been cancelled
Expose same-primary ranking on home/me for all staff including warehouse roles, and open the leaderboard API without business permission gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:02:42 +08:00
jacy aaf6ee81d5 merge(dev_jacy): fix mini-user production API origin 2026-07-17 09:30:12 +08:00
jacy 972bdde650 fix(mini-user): restore production API origin for H5 builds
CI / verify (pull_request) Has been cancelled
Production builds were baking localhost:3000 after the local-dev default change, causing failed to fetch on product list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 09:30:09 +08:00
jacy c0d66cfe50 merge(dev_jacy): admin订单配送/仓库自动发货/跨城起购与mini-user联调 2026-07-17 09:08:20 +08:00
jacy 00b52b9aaa 用户端跨城订单界面优化
CI / verify (pull_request) Has been cancelled
2026-07-17 09:06:53 +08:00
jacy e9537ce052 后端仓库配送逻辑完善
CI / verify (pull_request) Has been cancelled
2026-07-17 08:57:19 +08:00
jacy c204fb0a92 仓库自动配送开关
CI / verify (pull_request) Has been cancelled
2026-07-17 08:50:18 +08:00
jacy c643b0b979 webadmin端订单修改 2026-07-17 08:45:33 +08:00
jacy ed44e88e93 miniuser服务器连接地址修改
CI / verify (pull_request) Has been cancelled
2026-07-17 08:34:37 +08:00
jacy 57172d54d5 合伙人页面功
CI / verify (pull_request) Has been cancelled
2026-07-17 08:20:31 +08:00
jacy 608e405468 城市合伙人去掉订单补发功能,酒的订单如果没有配置仓库应该是看不到的
CI / verify (pull_request) Has been cancelled
2026-07-17 08:04:53 +08:00
jacy b36f2ea63a 城市合伙人H5端结算审核 2026-07-17 07:56:43 +08:00
jacy 7fa2c6fbed 城市合伙人端页面优化
CI / verify (pull_request) Has been cancelled
2026-07-17 07:45:20 +08:00
jacy bde85a7b82 仓库管理配置 小飞侠配置
CI / verify (pull_request) Has been cancelled
2026-07-16 21:40:25 +08:00
jacy b4ec9387a0 merge(dev_jacy): 仓配可调配履约与三分支推单 2026-07-16 17:51:32 +08:00
jacy f6d97b4ee8 feat(trade): 仓配可调配履约与三分支推单
CI / verify (pull_request) Has been cancelled
同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:51:20 +08:00
jacy f1dc23c54c 合伙人 H5 改造
CI / verify (pull_request) Has been cancelled
2026-07-16 15:04:56 +08:00
jacy 9e6dc8c85c merge(dev_jacy): partner UX, shop wechat bind, remove H5 title whitespace 2026-07-16 11:40:55 +08:00
jacy fd5427c7ea 页面头部的白色留白去掉
CI / verify (pull_request) Has been cancelled
2026-07-16 11:38:44 +08:00
jacy 4de570f5f4 合伙人端的子账号管理页面优化
CI / verify (pull_request) Has been cancelled
2026-07-16 11:24:48 +08:00
jacy 22845e5d5f 门店端增加绑定微信按钮
CI / verify (pull_request) Has been cancelled
2026-07-16 08:30:04 +08:00
jacy 8d7507e2d8 杜康发布 skills 2026-07-16 08:28:46 +08:00
jacy 48995aace4 merge(dev_jacy): hide H5 in-page titles vs WeChat chrome 2026-07-16 08:05:46 +08:00
jacy 5a26841d3a fix(h5): hide in-page nav titles that duplicate WeChat chrome
CI / verify (pull_request) Has been cancelled
Keep back buttons and header layout. mini-user only hides titles on H5; partner/shop hide .app-page-title and legal titles; shared PageHeader defaults showTitle=false.
2026-07-16 08:05:44 +08:00
jacy 92ad0e16cf merge(dev_jacy): fix order detail receiver address display 2026-07-15 21:36:47 +08:00
jacy 7b14f4a0cc fix(mini-user): show real receiver fields on order detail
CI / verify (pull_request) Has been cancelled
Order detail used non-existent addressSnapshot; API returns receiverName/phone/address. Render those and fix product qty from items/quantity.
2026-07-15 21:36:44 +08:00
jacy d69bfa359a merge(dev_jacy): fix account merge WeChat transfer and phone prompt 2026-07-15 21:25:45 +08:00
jacy d3d56150c5 fix(auth): transfer WeChat id on merge; stop false phone prompts
CI / verify (pull_request) Has been cancelled
Merge used to drop wxOpenId so OAuth created a new guest without phone every time. Now migrate WeChat identity, recover legacy merged openIds, return accountMerged and force page reload, and only soft-prompt when phone is truly unbound.
2026-07-15 21:25:34 +08:00
jacy 6a5ddcc70a merge(dev_jacy): fix phone bind session bounce and mini-user UX 2026-07-15 21:11:22 +08:00
jacy 5c55d3c385 fix(mini-user): persist bind-phone session and stop login bounce
CI / verify (pull_request) Has been cancelled
Phone bind discarded the new JWT after account merge, so the next page 401'd back to login. Also harden 401 race, location deny cache, unpaid repay CTA, and finishLoginNavigate.
2026-07-15 21:10:42 +08:00
jacy b30adfea8e merge(dev_jacy): fix H5 pay appid/openid and login input align 2026-07-15 20:55:13 +08:00
jacy 02aecbeaab fix(mini-user): H5 pay use USER_H5; center login phone input
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:55:09 +08:00
jacy 734a15c9ce merge(dev_jacy): mini-user address/share/input UX fixes 2026-07-15 20:48:36 +08:00
jacy 9a3ccabcc9 fix(mini-user): address textarea freeze, input align, wechat share invoke
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:48:33 +08:00
jacy fd62b5bdbe merge(dev_jacy): UX fixes for address, checkbox, scan resume, tabbar 2026-07-15 20:37:43 +08:00
jacy 966ddc9219 fix(mini-user): address URL, form align, agreement checkbox, shop scan resume
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:37:40 +08:00
jacy 52a809649c 用户端mini-user 多了一层tab 2026-07-15 20:31:09 +08:00
jacy e307550ead merge(dev_jacy): fix mini-user WeChat OAuth clientApp mismatch 2026-07-15 20:22:40 +08:00
jacy 372c29bc3a fix(mini-user): use USER_MINI for WeChat SDK so OAuth token persists
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:22:37 +08:00
jacy 9c45011bd4 merge(dev_jacy): fix WeChat login create type for deploy 2026-07-15 20:12:39 +08:00
jacy acc5b78471 fix(api): type WeChat-only user create for nest build
CI / verify (pull_request) Has been cancelled
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:12:18 +08:00
jacy d70f2d8f62 Merge pull request 'Dev jacy' (#8) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/8
2026-07-15 20:04:10 +08:00
jacy 5fe1ff1fc3 mock短信页面回显
CI / verify (pull_request) Has been cancelled
2026-07-15 20:03:06 +08:00
jacy 0f5f0f4c2b 用户端不要强制用户输入手机号 2026-07-15 19:57:06 +08:00
jacy f15d2057f2 登录页的登录按钮看不见 2026-07-15 19:51:57 +08:00
jacy 68aa7d3ce2 用户协议默认不勾选 2026-07-15 19:50:09 +08:00
jacy f6c77e60b6 merge(dev_jacy): fix mini-user H5 useDidShow blank tab crash 2026-07-15 19:34:00 +08:00
jacy 5a2e8dfcf8 fix(mini-user): dedupe Taro reactMeta so H5 useDidShow works
CI / verify (pull_request) Has been cancelled
Vite was bundling plugin-framework-react runtime twice, so tab pages crashed with useContext is not a function on enter/switch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 19:33:51 +08:00
764 changed files with 80262 additions and 28567 deletions
+3
View File
@@ -39,6 +39,9 @@ src/
## UI 约束
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
- C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
+2 -1
View File
@@ -29,4 +29,5 @@ chore(shared-types): add OrderStatus enum
## Agent 行为
- **仅用户明确要求时** 才执行 git commit / push
- 不 amend 已推送的 commit,不 force push main/master
- 不 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` 资料弹层。
@@ -23,7 +23,7 @@
| Guard | 用途 |
|-------|------|
| JwtAuthGuard | 需登录 |
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) |
| PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
| OptionalJwtAuthGuard | 可选登录(bootstrap |
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`
@@ -13,7 +13,7 @@
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
Vite 代理:`/api``localhost:3000`
Vite 代理:`/api``localhost:3010`(可用 `VITE_API_TARGET` 覆盖)
## 共享包
+91
View File
@@ -0,0 +1,91 @@
---
name: dukang-release
description: >-
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
or @杜康发布 / @dukang-release.
---
# 杜康发布
同机双栈:`/opt/dukang-staging`(测试)与 `/opt/dukang`(生产)。**仅用户明确要求发布时执行。**
## 分支与环境
| 分支 | 环境 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| `dev_jacy` | 日常开发 | 本机 | — | — |
| `dev` | **staging 测试** | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| `main` | **production 生产** | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
默认:`dev_jacy` → merge → `dev` → merge → `main`**发生产**。勿 force push `main`/`dev`
> **团队约定(2026-08)**:用户说「发布 / 发版 / 直接发版」且**未特别强调**时 → **发生产(production**。
> 仅当用户明确说「发测试 / staging / 测试环境」时 → 发 staging。
用户说「发生产 / 上线生产 / 发版」→ 用 `deploy-prod.sh`
用户说「发测试」→ 用 `deploy-staging.sh`
## 标准流程(Windows
PowerShell 下 **分支名必须加引号**
### A. 发测试(日常)
```powershell
# 1. 在 dev_jacy 提交(用户要求时)
git push -u origin HEAD
git checkout "dev"
git pull origin "dev"
git merge "dev_jacy" -m "merge(dev_jacy): <简述>"
git push origin "dev"
git checkout "dev_jacy"
# 2. 发 staging
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy-staging.sh" -- --skip-db
```
### B. 发生产(验收后)
```powershell
git checkout "main"
git pull origin "main"
git merge "dev" -m "merge(dev): <简述>"
git push origin "main"
git checkout "dev_jacy"
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy-prod.sh" -- --skip-db
```
- 默认加 `--skip-db`(无迁移时)
- 有 Prisma 迁移:先在 **staging** 跑通(可 `--seed` / `--accept-data-loss`),再生产去掉 `--skip-db`
- Staging 微信:正式号 + 全 Mock`.env.staging`
- `deploy/deploy.env` 提供 `DEPLOY_HOST``STAGING_APP_ROOT``PROD_APP_ROOT`(勿提交密钥)
### 首次启用 Staging
服务器一次性:`bash deploy/bootstrap-staging.sh`(见脚本说明)→ 配 `.env.staging` → DNS `*-test` → nginx → `deploy-staging.sh -- --seed`
### 同步环境变量
```powershell
bash deploy/sync-api-env.sh staging
bash deploy/sync-api-env.sh production
```
## 等待完成
- 出现 `发版完成`
- `exit_code: 0`
- `mini-user(h5): 200` / `api: 200`
## 回复用户
说明:环境(测试/生产)、commit、建议硬刷验证。测试后台有橙色「测试环境」条。
## 禁止
- 不 amend 已推送 commit;不 force push `main`/`dev`
- 不把 `.env*` 私钥打进仓库
- **禁止**再用 `dev` 直发生产
- 用户只说「修好」未说发布 → 只改代码,不发版
+1 -1
View File
@@ -51,7 +51,7 @@
|-----|------|----------|
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
| ACC-012 | 代下单 | W3手机号建用户;权益自动发 |
| ACC-012 | 代下单 | W3客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
| ACC-018 | 话术 | OPT-012 3 日内对齐 |
@@ -7,7 +7,7 @@
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
| REQ-U-002 | 登录 | 无感登录;下单强制手机号;微信/短信;7 天免登 |
| REQ-U-002 | 登录 | 无感登录;下单提示绑定手机号(可选);微信/短信;7 天免登 |
| REQ-U-003 | 支付 | 仅微信支付 |
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
@@ -32,6 +32,7 @@
| REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 |
| REQ-U-025 | 分享 | 微信商品分享+渠道参数 |
| REQ-U-026 | 门店规则 | 附件一完整展示 |
| REQ-U-027 | 门店套餐 **3.4.10** | 门店详情展示生效套餐;套餐异议申诉入口 |
## 门店端 REQ-S
@@ -58,6 +59,7 @@
| REQ-S-018 | 权限 | §4.6 矩阵 |
| REQ-S-019 | 多店选店 | 记住上次所选 |
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
| REQ-S-021 | 门店套餐 **3.4.10** | 套餐列表/编辑/提审 |
## 合伙人端 REQ-P
@@ -89,6 +91,7 @@
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
| REQ-P-025 | 合同同日 | 同日提交校验 |
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
| REQ-P-027 | 门店套餐 **3.4.10** | 拓店套餐页;门店套餐列表/编辑/提审 |
## 总部端 REQ-H
@@ -122,3 +125,4 @@
| REQ-H-022 | 签约主体 | 山西领势酒业 |
| REQ-H-023 | 热力图 | 腾讯位置服务 |
| REQ-H-024 | 结算异议 | 门店 3 日异议处理 |
| REQ-H-025 | 门店套餐 **3.4.10** | 门店详情套餐 Tab 直存;套餐变更审核通过/驳回 |
+7
View File
@@ -8,10 +8,17 @@ server/dukang-api/.env
server/dukang-api/.env.development
server/dukang-api/.env.production
server/dukang-api/.env.production.local
server/dukang-api/.env.staging
server/dukang-api/.env.staging.local
deploy/deploy.env
deploy/.staging-mysql.pass
deploy/_ops-*.log
deploy/_ops-*.sh
.DS_Store
coverage/
*.tsbuildinfo
server/dukang-api/prisma/migrations/
debug_v3.xlsx
~$debug_v3.xlsx
deploy/auto-release.env
+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矛盾,疑似另一轮实验性改动。已与用户确认前**暂不提交**。
+22 -3
View File
@@ -11,7 +11,7 @@
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
| ~~V2 / preV1~~ | 历史参考**已压缩**V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
@@ -28,7 +28,7 @@ pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端)
pnpm dev:api # http://localhost:3000/api/v1
pnpm dev:api # http://localhost:3010/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
@@ -40,7 +40,7 @@ node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
@@ -90,6 +90,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| 需求 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(按职责选用)
@@ -115,6 +116,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
@@ -135,6 +137,23 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
**微信小程序页面标题**weapp 只用原生 `navigationBarTitleText`,不要再画一层与导航栏重复的 `SubPageHeader` title;见 `.cursor/rules/mini-user-weapp-nav-title.mdc`
## 环境与发版
| 环境 | 分支 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev`
- 生产发布 → `deploy/deploy-prod.sh``main`,验收后)
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example`
- 详情:`@dukang-release` / `.cursor/skills/dukang-release/SKILL.md`
## 提交与 PR
- Conventional Commits`feat(trade):``fix(redeem):`scope = 端或模块
+1
View File
@@ -0,0 +1 @@
ayPJ4CQqbUcec3jX
+1
View File
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
- 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed`
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
## 新页面 workflow
+4
View File
@@ -10,9 +10,13 @@
},
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"antd": "^5.22.0",
"dayjs": "^1.11.13",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"element-china-area-data": "^6.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+52
View File
@@ -5,7 +5,10 @@ import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage';
import BigScreenPage from './pages/BigScreenPage';
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
import StoresPage from './pages/StoresPage';
import StoreRatingsPage from './pages/StoreRatingsPage';
import StoreAccountsPage from './pages/StoreAccountsPage';
import BenefitCouponsPage from './pages/BenefitCouponsPage';
import BenefitLedgersPage from './pages/BenefitLedgersPage';
@@ -18,7 +21,9 @@ import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
@@ -29,8 +34,12 @@ import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage';
import DomainEventsPage from './pages/DomainEventsPage';
import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
import StoreLogsPage from './pages/StoreLogsPage';
@@ -38,6 +47,15 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage';
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage';
import LlmConfigsPage from './pages/LlmConfigsPage';
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
import DevPlanVersionsPage from './pages/DevPlanVersionsPage';
import DevPlanTasksPage from './pages/DevPlanTasksPage';
import DevPlanSettingsPage from './pages/DevPlanSettingsPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
@@ -48,6 +66,14 @@ export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/orders/big-screen"
element={
<RequireAuth>
<BigScreenPage />
</RequireAuth>
}
/>
<Route
element={
<RequireAuth>
@@ -64,9 +90,21 @@ export default function App() {
<Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} />
</Route>
<Route path="/wecom-bots" element={<Navigate to="/wecom/bots" replace />} />
<Route path="/wecom/bots" element={<WecomBotsPage />} />
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
<Route path="/llm-configs" element={<LlmConfigsPage />} />
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
<Route path="/dev-plan/versions" element={<DevPlanVersionsPage />} />
<Route path="/dev-plan/tasks" element={<DevPlanTasksPage />} />
<Route path="/dev-plan/settings" element={<DevPlanSettingsPage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/store-package-audits" element={<StorePackageAuditsPage />} />
<Route path="/store-ratings" element={<StoreRatingsPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} />
@@ -74,6 +112,7 @@ export default function App() {
<Route path="/cities" element={<CitiesPage />} />
<Route path="/city-partners" element={<CityPartnersPage />} />
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
@@ -81,20 +120,33 @@ export default function App() {
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route
path="/finance/store-withdrawals"
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
/>
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route
path="/store-withdrawals"
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
/>
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/tickets/support" element={<SupportTicketsPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/partners" element={<PartnerLogsPage />} />
<Route path="/logs/hq" element={<HqLogsPage />} />
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
<Route path="/logs/domain-events" element={<DomainEventsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
+653
View File
@@ -86,3 +86,656 @@ body,
.admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap;
}
.admin-package-audit-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.admin-package-audit-col {
min-width: 0;
}
.admin-package-audit-text {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
max-width: none;
}
/* v3.5.1 #1 订单大屏:发布会现场 */
html:has(.big-screen-page),
body:has(.big-screen-page),
#root:has(.big-screen-page) {
overflow: hidden;
height: 100%;
}
.big-screen-page {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: radial-gradient(ellipse at 50% 18%, #163a6b 0%, #0b1e3a 42%, #061224 100%);
color: #d6e8ff;
padding: 20px 48px 24px;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
.big-screen-stars {
pointer-events: none;
position: absolute;
inset: 0;
background-image:
radial-gradient(1px 1px at 8% 18%, rgba(255, 255, 255, 0.35), transparent),
radial-gradient(1px 1px at 22% 72%, rgba(160, 210, 255, 0.28), transparent),
radial-gradient(1.5px 1.5px at 78% 24%, rgba(255, 255, 255, 0.22), transparent),
radial-gradient(1px 1px at 91% 68%, rgba(160, 210, 255, 0.3), transparent),
radial-gradient(1px 1px at 46% 88%, rgba(255, 255, 255, 0.18), transparent),
radial-gradient(1.5px 1.5px at 61% 12%, rgba(160, 210, 255, 0.25), transparent);
opacity: 0.7;
}
.big-screen-frame {
pointer-events: none;
position: absolute;
inset: 14px 18px;
}
.big-screen-corner {
position: absolute;
width: 28px;
height: 28px;
border: 1px solid rgba(105, 192, 255, 0.7);
}
.big-screen-corner--tl {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
}
.big-screen-corner--tr {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
}
.big-screen-corner--bl {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
}
.big-screen-corner--br {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
}
.big-screen-header {
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid rgba(105, 192, 255, 0.18);
}
.big-screen-brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.big-screen-title {
margin: 0;
font-size: 28px;
line-height: 1;
font-weight: 700;
letter-spacing: 4px;
color: #fff;
}
.big-screen-subtitle {
font-size: 16px;
color: rgba(200, 220, 245, 0.55);
letter-spacing: 2px;
}
.big-screen-live-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.big-screen-live-label {
font-size: 18px;
color: rgba(230, 244, 255, 0.88);
letter-spacing: 2px;
}
.big-screen-live {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 10px;
border: 1px solid rgba(64, 169, 255, 0.7);
border-radius: 6px;
font-size: 14px;
font-weight: 700;
color: #fff;
letter-spacing: 2px;
background: rgba(8, 28, 56, 0.55);
}
.big-screen-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff4d4f;
box-shadow: 0 0 8px #ff4d4f;
animation: big-screen-pulse 1.2s ease-in-out infinite;
}
@keyframes big-screen-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.75);
}
}
.big-screen-hero {
position: relative;
z-index: 1;
flex: 0 0 auto;
text-align: center;
padding: 16px 0 12px;
}
.big-screen-clock {
font-size: 84px;
line-height: 1;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 10px;
color: #9fd3ff;
text-shadow: 0 0 28px rgba(105, 192, 255, 0.45);
}
.big-screen-date {
margin-top: 8px;
font-size: 16px;
letter-spacing: 4px;
color: rgba(180, 210, 240, 0.65);
}
.big-screen-list {
position: relative;
z-index: 1;
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.big-screen-list-head,
.big-screen-row {
display: grid;
grid-template-columns: minmax(180px, 18%) minmax(0, 1fr) minmax(140px, 16%) minmax(160px, 18%);
align-items: center;
column-gap: 16px;
width: 100%;
box-sizing: border-box;
}
.big-screen-list-body {
flex: 1 1 0%;
min-height: 0;
height: 0;
overflow: hidden;
position: relative;
}
.big-screen-track {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
flex-direction: column;
}
.big-screen-track.is-rolling {
animation: big-screen-marquee-up var(--marquee-ms, 20s) linear infinite;
}
.big-screen-track.is-paused {
animation-play-state: paused;
}
@keyframes big-screen-marquee-up {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
.big-screen-row {
position: relative;
flex: 0 0 auto;
height: 68px;
margin-bottom: 12px;
padding: 0 28px 0 40px;
background: rgba(18, 48, 88, 0.45);
border: 1px solid transparent;
border-radius: 4px;
font-size: 22px;
color: #e8f4ff;
box-sizing: border-box;
}
.big-screen-row.is-latest {
border-color: rgba(64, 169, 255, 0.85);
box-shadow: 0 0 16px rgba(24, 144, 255, 0.28);
}
.big-screen-row--t1 {
background: rgba(22, 54, 98, 0.48);
border-color: rgba(105, 192, 255, 0.16);
}
.big-screen-row--t1 .big-screen-amount {
color: #e6f4ff;
}
.big-screen-row--t2.is-latest {
border-color: rgba(255, 229, 143, 0.95);
box-shadow: 0 0 18px rgba(255, 229, 143, 0.42);
}
.big-screen-row--t3.is-latest {
border-color: #ffd666;
box-shadow: 0 0 22px rgba(250, 173, 20, 0.55);
}
.big-screen-row--t2 {
background: rgba(64, 48, 8, 0.42);
border-color: rgba(255, 229, 143, 0.45);
color: #fff7d6;
}
.big-screen-row--t2 .big-screen-amount {
color: #ffe58f;
text-shadow: 0 0 10px rgba(255, 229, 143, 0.45);
}
.big-screen-row--t3 {
background: rgba(72, 48, 0, 0.5);
border-color: rgba(250, 173, 20, 0.75);
color: #ffe7a3;
box-shadow: 0 0 18px rgba(250, 173, 20, 0.28);
}
.big-screen-row--t3 .big-screen-amount {
color: #ffd666;
text-shadow: 0 0 14px rgba(255, 214, 102, 0.7);
}
.big-screen-row-mark {
position: absolute;
left: 12px;
top: 50%;
width: 10px;
height: 22px;
margin-top: -11px;
border-radius: 6px;
background: #40a9ff;
box-shadow: 0 0 10px #40a9ff;
}
.big-screen-row--t2 .big-screen-row-mark {
background: #ffe58f;
box-shadow: 0 0 10px #ffe58f;
}
.big-screen-row--t3 .big-screen-row-mark {
background: #ffd666;
box-shadow: 0 0 12px #ffd666;
}
.big-screen-amount {
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
letter-spacing: 0.5px;
min-width: 0;
}
.big-screen-amount--6d {
font-size: 0.86em;
letter-spacing: 0;
}
.big-screen-items {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.big-screen-time,
.big-screen-phone {
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
white-space: nowrap;
}
.big-screen-empty {
position: absolute;
left: 0;
right: 0;
/* 全屏高度黄金分割点(≈0.382),略偏上 */
top: 38.2%;
transform: translateY(-50%);
z-index: 2;
text-align: center;
color: rgba(145, 190, 230, 0.42);
padding: 0 40px;
font-size: 60px;
letter-spacing: 6px;
font-weight: 400;
pointer-events: none;
}
.big-screen-fx {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
overflow: hidden;
background: rgba(4, 12, 28, 0.38);
}
.big-screen-fx-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.big-screen-fx-shock {
position: absolute;
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid rgba(255, 214, 102, 0.85);
animation: big-screen-shock 1.1s ease-out forwards;
}
@keyframes big-screen-shock {
0% {
transform: scale(0.2);
opacity: 1;
}
100% {
transform: scale(28);
opacity: 0;
}
}
.big-screen-fx-sweep {
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 38%,
rgba(255, 245, 200, 0.22) 50%,
transparent 62%
);
background-size: 220% 100%;
animation: big-screen-sweep 1.4s ease-out 0.15s both;
}
.big-screen-fx-sweep--alt {
animation-delay: 0.55s;
background: linear-gradient(
65deg,
transparent 38%,
rgba(255, 214, 102, 0.2) 50%,
transparent 62%
);
}
.big-screen-fx-shock--late {
animation-delay: 0.35s;
border-color: rgba(255, 236, 179, 0.55);
}
@keyframes big-screen-sweep {
from {
background-position: 120% 0;
opacity: 0;
}
30% {
opacity: 1;
}
to {
background-position: -40% 0;
opacity: 0;
}
}
.big-screen-fx-card {
position: relative;
z-index: 2;
min-width: min(420px, 92vw);
max-width: min(920px, 92vw);
padding: 28px 40px 32px;
border-radius: 12px;
text-align: center;
background: rgba(8, 22, 48, 0.82);
backdrop-filter: blur(8px);
box-sizing: border-box;
}
.big-screen-fx-card--t1 {
border: 2px solid rgba(105, 192, 255, 0.85);
box-shadow: 0 0 32px rgba(24, 144, 255, 0.45);
animation: big-screen-card-in-t1 0.55s cubic-bezier(0.2, 0.9, 0.2, 1) both;
}
.big-screen-fx-card--t2 {
border: 2px solid rgba(255, 229, 143, 0.95);
box-shadow: 0 0 40px rgba(255, 214, 102, 0.5);
animation: big-screen-card-in-t2 0.6s cubic-bezier(0.16, 1.2, 0.3, 1) both;
}
.big-screen-fx-card--t3 {
min-width: min(560px, 94vw);
padding: 36px 48px 40px;
border: 3px solid #ffd666;
box-shadow:
0 0 28px rgba(255, 214, 102, 0.85),
0 0 80px rgba(250, 173, 20, 0.45);
animation: big-screen-card-in-t3 0.7s cubic-bezier(0.12, 1.4, 0.2, 1) both;
}
@keyframes big-screen-card-in-t1 {
from {
opacity: 0;
transform: translateY(80px) scale(0.92);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes big-screen-card-in-t2 {
0% {
opacity: 0;
transform: scale(0.6);
}
70% {
transform: scale(1.06);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes big-screen-card-in-t3 {
0% {
opacity: 0;
transform: scale(0.4) rotate(-4deg);
}
55% {
transform: scale(1.12) rotate(1deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(0);
}
}
.big-screen-fx-kicker {
font-size: 16px;
letter-spacing: 8px;
color: rgba(230, 244, 255, 0.7);
margin-bottom: 8px;
}
.big-screen-fx-card--t2 .big-screen-fx-kicker,
.big-screen-fx-card--t3 .big-screen-fx-kicker {
color: #ffe58f;
}
.big-screen-fx-amount {
font-size: clamp(44px, 8.5vw, 64px);
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
line-height: 1.1;
color: #e6f7ff;
max-width: 100%;
margin-inline: auto;
}
.big-screen-fx-amount--6d {
font-size: clamp(36px, 7vw, 52px) !important;
letter-spacing: 1px;
}
.big-screen-fx-card--t1 .big-screen-fx-amount {
color: #91d5ff;
text-shadow: 0 0 18px rgba(105, 192, 255, 0.6);
}
.big-screen-fx-card--t2 .big-screen-fx-amount {
color: #ffe58f;
text-shadow: 0 0 20px rgba(255, 229, 143, 0.7);
animation: big-screen-amount-pop 0.8s ease-out 0.15s both;
}
.big-screen-fx-card--t3 .big-screen-fx-amount {
font-size: clamp(52px, 10vw, 84px);
color: #ffd666;
text-shadow:
0 0 12px #ffd666,
0 0 36px rgba(250, 173, 20, 0.8);
animation: big-screen-amount-flash 0.9s ease-in-out infinite;
}
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
font-size: clamp(44px, 8.5vw, 68px) !important;
letter-spacing: 1px;
}
@keyframes big-screen-amount-pop {
0% {
transform: scale(0.7);
}
70% {
transform: scale(1.12);
}
100% {
transform: scale(1);
}
}
@keyframes big-screen-amount-flash {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(1.35);
}
}
.big-screen-fx-items {
margin-top: 12px;
font-size: 22px;
color: #fff;
}
.big-screen-fx-meta {
margin-top: 10px;
display: flex;
justify-content: center;
gap: 28px;
font-size: 16px;
color: rgba(210, 228, 250, 0.75);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1200px) {
.big-screen-clock {
font-size: 56px;
}
.big-screen-row {
grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
font-size: 16px;
}
.big-screen-amount--6d {
font-size: 0.82em;
}
.big-screen-fx-amount {
font-size: clamp(36px, 8vw, 44px);
}
.big-screen-fx-card--t3 .big-screen-fx-amount {
font-size: clamp(40px, 9vw, 56px);
}
.big-screen-fx-amount--6d {
font-size: clamp(32px, 6.5vw, 40px) !important;
}
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
font-size: clamp(34px, 7vw, 48px) !important;
}
}
@@ -0,0 +1,381 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request } from '../lib/api';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto;
export type AdminStorePackagesHandle = {
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
};
function emptyRow(index = 0): PackageRow {
return {
name: '',
price: '0',
dishes: '',
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
}
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
function AdminStorePackagesSection({ storeId }, ref) {
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
const itemsRef = useRef(items);
const loadingRef = useRef(loading);
const navigate = useNavigate();
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
loadingRef.current = loading;
}, [loading]);
useEffect(() => {
setLoading(true);
setPendingRequest(null);
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => {
setPendingRequest(data.pendingRequest ?? null);
setItems(
data.live?.length
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false));
}, [storeId]);
// 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
useEffect(() => {
const onChanged = () => {
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => setPendingRequest(data.pendingRequest ?? null))
.catch(() => undefined);
};
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
}, [storeId]);
function goAudit() {
if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`);
}
const pendingReminder =
pendingRequest && pendingRequest.status === 'PENDING' ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="该门店有待审核套餐"
description={
<Space>
<Button size="small" type="primary" onClick={goAudit}>
</Button>
<Button size="small" onClick={goAudit}>
</Button>
</Space>
}
/>
) : null;
function updateAt(index: number, patch: Partial<PackageRow>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addRow() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
setItems((prev) => [...prev, emptyRow(prev.length)]);
}
function removeAt(index: number) {
const run = () => {
setItems((prev) => {
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
return next.length ? next : [];
});
setCollapsed((prev) => {
const next: Record<number, boolean> = {};
Object.entries(prev).forEach(([k, v]) => {
const i = Number(k);
if (i < index) next[i] = v;
else if (i > index) next[i - 1] = v;
});
return next;
});
};
if (items.length === 1) {
Modal.confirm({
title: '清空门店套餐',
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
okText: '确认删除',
cancelText: '取消',
onOk: run,
});
return;
}
run();
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
async function save(opts?: { quiet?: boolean }) {
const currentItems = itemsRef.current;
const filled = currentItems
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
imageUrl: imageUrls[0] ?? null,
imageUrls,
sortOrder: index,
};
})
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
.filter((item) => {
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
const hasImages = item.imageUrls.length > 0;
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
return hasText || hasImages || hasNonZeroPrice;
});
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
throw new Error('套餐校验失败');
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
throw new Error('套餐校验失败');
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
throw new Error('套餐校验失败');
}
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`);
throw new Error('套餐校验失败');
}
}
setSaving(true);
try {
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
method: 'PUT',
body: JSON.stringify({
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
if (!opts?.quiet) message.success('套餐已保存并生效');
setItems(
data.live?.length
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
} 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) 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>
</Form>
);
},
);
export default AdminStorePackagesSection;
@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { Select } from 'antd';
import { getDistrictOptionsByCityCode } from '../lib/china-region';
type Props = {
cityCode?: string | null;
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
disabled?: boolean;
};
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
export default function CityDistrictMultiSelect({
cityCode,
value,
onChange,
placeholder,
disabled,
}: Props) {
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
const ready = Boolean(cityCode) && options.length > 0;
return (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
value={value}
onChange={onChange}
disabled={disabled || !ready}
placeholder={
placeholder ??
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
}
options={options}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
);
}
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Cascader,
Checkbox,
Drawer,
Form,
@@ -27,8 +26,9 @@ import {
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = {
@@ -37,6 +37,7 @@ type PartnerRow = {
phone: string;
name: string;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
accountCount: number;
@@ -65,6 +66,7 @@ type PartnerDetail = PartnerRow & {
type Props = {
cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number;
onChanged?: () => void;
};
@@ -92,14 +94,31 @@ function commissionSumError(
redeemPercent: number,
maxRate: number,
): string | null {
const sum = orderPercent / 100 + redeemPercent / 100;
if (sum > maxRate + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) {
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPanel({
cityId,
cityCode,
maxPartnerCommissionRate = 0.05,
onChanged,
}: Props) {
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
@@ -153,29 +172,34 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
async function savePartner() {
if (!detail) return;
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
try {
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
}
async function deleteSubAccount(subId: string) {
@@ -187,6 +211,14 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
}
const columns: ColumnsType<PartnerRow> = [
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 120 },
{
@@ -249,11 +281,11 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
@@ -261,8 +293,12 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县">
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
@@ -292,7 +328,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(row) => {
@@ -316,42 +352,51 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
</Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
try {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { Button, Space } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import {
parseMiniHomeBanners,
serializeMiniHomeBanners,
} from '@dukang/shared-types';
import OssUpload from './OssUpload';
const MAX_BANNERS = 8;
/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */
function parseBannersForEdit(raw?: string | null): string[] {
if (!raw?.trim()) return [];
try {
const parsed = JSON.parse(raw.trim()) as unknown;
if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw);
return parsed
.filter((u): u is string => typeof u === 'string')
.map((u) => u.trim())
.slice(0, MAX_BANNERS);
} catch {
return parseMiniHomeBanners(raw);
}
}
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer */
export function ConfigImageField({
value,
onChange,
bizType = 'footer',
}: {
value?: string;
onChange?: (url: string) => void;
bizType?: string;
}) {
return (
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={value ?? ''}
onChange={(url) => onChange?.(url ?? '')}
placeholder="上传或粘贴图片 URL"
/>
);
}
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper */
export function ConfigImageListField({
value,
onChange,
bizType = 'swiper',
}: {
value?: string;
onChange?: (json: string) => void;
bizType?: string;
}) {
const [urls, setUrls] = useState<string[]>(() => parseBannersForEdit(value));
useEffect(() => {
setUrls(parseBannersForEdit(value));
}, [value]);
/** 本地可含空位;写入 Form 时去掉空串 */
function commit(next: string[]) {
const clipped = next.slice(0, MAX_BANNERS);
setUrls(clipped);
onChange?.(serializeMiniHomeBanners(clipped));
}
function updateAt(index: number, url: string) {
const next = [...urls];
next[index] = url;
commit(next);
}
function removeAt(index: number) {
commit(urls.filter((_, i) => i !== index));
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= urls.length) return;
const next = [...urls];
const tmp = next[index];
next[index] = next[target];
next[target] = tmp;
commit(next);
}
function add() {
if (urls.length >= MAX_BANNERS) return;
// 只加本地空位,避免 serialize 过滤空串导致「点击无反应」
setUrls((prev) => [...prev, '']);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{urls.map((url, index) => (
<Space key={`banner-${index}`} align="start" style={{ width: '100%' }} wrap>
<div style={{ flex: 1, minWidth: 240 }}>
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={url}
onChange={(u) => updateAt(index, u)}
placeholder="上传或粘贴图片 URL"
/>
</div>
<Space>
<Button
type="text"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => move(index, -1)}
/>
<Button
type="text"
icon={<ArrowDownOutlined />}
disabled={index === urls.length - 1}
onClick={() => move(index, 1)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
</Space>
</Space>
))}
<Button
type="dashed"
block
icon={<PlusOutlined />}
disabled={urls.length >= MAX_BANNERS}
onClick={add}
>
{urls.length}/{MAX_BANNERS}
</Button>
</Space>
);
}
@@ -1,6 +1,5 @@
import { Button, Form, Space, Typography } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import OssUpload from './OssUpload';
import { Typography } from 'antd';
import MultiImageUpload from './MultiImageUpload';
type Props = {
name?: string;
@@ -8,42 +7,35 @@ type Props = {
bizType?: string;
/** 最多可添加张数;不传则不限制 */
maxCount?: number;
/** Form.Item 注入 */
value?: string[];
onChange?: (urls: string[]) => void;
};
/**
* 商品详情/轮播等多图列表。
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
*/
export default function DetailImageUrlList({
name = 'detailImageUrls',
label,
bizType = 'DETAIL',
maxCount,
value,
onChange,
}: Props) {
return (
<>
{maxCount != null && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{maxCount} {label}
</Typography.Text>
)}
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
{(!maxCount || fields.length < maxCount) && (
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
)}
</>
)}
</Form.List>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{label}{maxCount != null ? `,最多 ${maxCount}` : ''}
</Typography.Text>
<MultiImageUpload
bizType={bizType}
mediaType="IMAGE"
maxCount={maxCount}
value={value}
onChange={onChange}
tip={`支持一次选择多张${label}`}
/>
</>
);
}
@@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Image, Space, Typography, Upload, message } from 'antd';
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
bizType: string;
mediaType?: OssMediaType;
/** 最多张数;不传则不限制 */
maxCount?: number;
tip?: string;
accept?: string;
/** 上传按钮文案,默认「批量上传图片」 */
buttonText?: string;
};
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
function isPdf(url: string) {
return /\.pdf(\?|$)/i.test(url);
}
/**
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
* Form.Item 直接绑定 string[]。
*/
export default function MultiImageUpload({
value,
onChange,
bizType,
mediaType = 'IMAGE',
maxCount,
tip,
accept = 'image/*',
buttonText = '批量上传图片',
}: Props) {
const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
const onChangeRef = useRef(onChange);
const [uploading, setUploading] = useState(false);
const batchBuf = useRef<File[]>([]);
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const uploadChain = useRef(Promise.resolve());
useEffect(() => {
urlsRef.current = urls;
}, [urls]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
return () => {
if (batchTimer.current) clearTimeout(batchTimer.current);
};
}, []);
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
const canAdd = remaining > 0;
async function uploadBatch(files: File[]) {
const current = urlsRef.current;
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
const picked = files.slice(0, room);
if (!picked.length) {
message.warning(maxCount != null ? `最多 ${maxCount}` : '无法上传');
return;
}
if (files.length > picked.length) {
message.warning(`已达上限,仅上传前 ${picked.length}`);
}
setUploading(true);
const appended: string[] = [];
let fail = 0;
try {
for (const file of picked) {
try {
const result = await uploadFileToOss(file, { bizType, mediaType });
appended.push(result.url);
} catch {
fail += 1;
}
}
if (appended.length) {
// 始终基于最新列表追加,避免并行上传互相覆盖
const next = [...urlsRef.current, ...appended];
urlsRef.current = next;
onChangeRef.current?.(next);
message.success(`成功上传 ${appended.length}${fail ? `,失败 ${fail}` : ''}`);
} else if (fail) {
message.error('上传失败');
}
} finally {
setUploading(false);
}
}
function enqueueUploadBatch(files: File[]) {
uploadChain.current = uploadChain.current
.then(() => uploadBatch(files))
.catch(() => undefined);
}
function flushBatch() {
if (batchTimer.current) {
clearTimeout(batchTimer.current);
batchTimer.current = null;
}
if (!batchBuf.current.length) return;
const files = [...batchBuf.current];
batchBuf.current = [];
enqueueUploadBatch(files);
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
batchBuf.current.push(file as File);
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
if (batchTimer.current) clearTimeout(batchTimer.current);
batchTimer.current = setTimeout(() => {
flushBatch();
}, 80);
return false;
};
function removeAt(index: number) {
const next = urlsRef.current.filter((_, i) => i !== index);
urlsRef.current = next;
onChangeRef.current?.(next);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{tip ??
(maxCount != null
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
: '支持一次选择多张批量上传')}
{maxCount != null ? `(已选 ${urls.length}/${maxCount}` : urls.length ? `(已选 ${urls.length}` : ''}
</Typography.Text>
{urls.length > 0 ? (
<Image.PreviewGroup>
<Space wrap size={12}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: 96,
height: 96,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 4,
borderRadius: 6,
border: '1px solid #f0f0f0',
background: '#fafafa',
fontSize: 12,
}}
>
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
<span>PDF</span>
</a>
) : (
<Image
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
)}
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeAt(index)}
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'rgba(255,255,255,0.85)',
}}
/>
</div>
))}
</Space>
</Image.PreviewGroup>
) : null}
<Upload
accept={accept}
multiple
showUploadList={false}
beforeUpload={beforeUpload}
disabled={uploading || !canAdd}
>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
{canAdd ? buttonText : '已达上限'}
</Button>
</Upload>
</Space>
);
}
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Empty,
Image,
Space,
Spin,
Timeline,
Typography,
message,
} from 'antd';
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
import { isXfxProviderCode } from '@dukang/shared-types';
import { request } from '../lib/api';
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
type Props = {
open: boolean;
orderId: string | null;
orderNo?: string | null;
orderStatus?: string | null;
onClose: () => void;
};
function providerLabel(provider?: string | null, company?: string | null) {
if (company) return company;
if (!provider) return '—';
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
if (isXfxProviderCode(provider)) return '小飞侠';
return provider;
}
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
return [...nodes].sort((a, b) => {
const ta = new Date(a.createTime).getTime();
const tb = new Date(b.createTime).getTime();
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
});
}
export default function OrderTrackDrawer({
open,
orderId,
orderNo,
orderStatus,
onClose,
}: Props) {
const [loading, setLoading] = useState(false);
const [track, setTrack] = useState<OrderTrackDto | null>(null);
const load = useCallback(async () => {
if (!orderId) return;
setLoading(true);
try {
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
setTrack(data);
} catch (e) {
setTrack(null);
message.error(e instanceof Error ? e.message : '加载路由失败');
} finally {
setLoading(false);
}
}, [orderId]);
useEffect(() => {
if (!open || !orderId) {
setTrack(null);
return;
}
void load();
}, [open, orderId, load]);
const nodes = useMemo(
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
[track?.nodes],
);
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
orderStatus || '',
);
return (
<Drawer
title="物流路由"
width={520}
open={open}
onClose={onClose}
extra={
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
</Button>
}
>
<Spin spinning={loading}>
{(orderNo || track?.trackingNo || track?.provider) && (
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
<Descriptions.Item label="配送方式">
{providerLabel(track?.provider, track?.logisticsCompany)}
</Descriptions.Item>
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
{track?.manualQueryUrl ? (
<Descriptions.Item label="物流查询">
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
</a>
</Descriptions.Item>
) : null}
{track?.estimatedArrival?.arriveTime ? (
<Descriptions.Item label="预计送达">
{fmtTime(track.estimatedArrival.arriveTime)}
{track.estimatedArrival.siteName
? `${track.estimatedArrival.siteName}`
: ''}
</Descriptions.Item>
) : null}
</Descriptions>
)}
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
<div style={{ marginBottom: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<Image.PreviewGroup>
<Space wrap>
{track.signPhotoUrls.map((url) => (
<Image
key={url}
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
))}
</Space>
</Image.PreviewGroup>
</div>
) : null}
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{nodes.length > 0 ? (
<Timeline
items={nodes.map((node, index) => {
const isLatest = index === nodes.length - 1;
return {
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
children: (
<div>
{node.statusName ? (
<Typography.Text strong style={{ display: 'block' }}>
{node.statusName}
</Typography.Text>
) : null}
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
{node.trackInfo || '—'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(node.createTime)}
</Typography.Text>
</div>
),
};
})}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
track?.manualQueryUrl
? '暂无实时路由节点,可使用上方物流查询链接'
: '暂无路由信息,请稍后刷新'
}
/>
)}
</Spin>
</Drawer>
);
}
+62 -10
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
@@ -15,7 +15,13 @@ type OssUploadProps = {
placeholder?: string;
};
const DEFAULT_MAX_MB = 10;
function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
export default function OssUpload({
value,
@@ -24,22 +30,16 @@ export default function OssUpload({
bizType,
mediaType = 'IMAGE',
accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true);
try {
const result = await uploadFileToOss(raw, { bizType, mediaType });
@@ -56,6 +56,57 @@ export default function OssUpload({
}
};
const filePreview =
value && mediaType === 'FILE' ? (
isImageUrl(value) ? (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : isPdfUrl(value) ? (
<Space direction="vertical" size={8}>
<div
style={{
width: 120,
height: 120,
borderRadius: 4,
border: '1px solid #f0f0f0',
background: '#fafafa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 36 }} />
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF </span>
</div>
<Space wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
</Button>
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
</Button>
</Space>
<Modal
title="签约合同预览"
open={pdfPreviewOpen}
onCancel={() => setPdfPreviewOpen(false)}
footer={null}
width="90vw"
styles={{ body: { height: '75vh', padding: 0 } }}
destroyOnClose
>
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
</Modal>
</Space>
) : (
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
</Button>
)
) : null;
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && (
@@ -64,6 +115,7 @@ export default function OssUpload({
{value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)}
{filePreview}
<Space wrap>
<Upload
accept={resolvedAccept}
@@ -0,0 +1,21 @@
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
import MultiImageUpload from './MultiImageUpload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
};
/** 套餐多图:批量上传,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
export default function PackageImagesUpload({ value, onChange }: Props) {
return (
<MultiImageUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
value={value}
onChange={onChange}
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
/>
);
}
@@ -0,0 +1,616 @@
import { useMemo, useState } from 'react';
import {
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
} from 'antd';
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { request } from '../lib/api';
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
import OssUpload from './OssUpload';
type SpecValue = { id?: string; name: string; sortOrder?: number };
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
type SkuRow = {
id?: string;
specValueIds: string[];
skuCode?: string;
barcode69: string;
price: number;
benefitAmount?: number;
status: string;
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
allowOnSitePickup: boolean;
saleUnit: 'BOTTLE' | 'BOX';
bottlesPerUnit: number;
isDefault: boolean;
sortOrder?: number;
specText?: string;
imageUrl?: string | null;
};
function cartesian(attrs: SpecAttr[]): string[][] {
if (!attrs.length) return [[]];
return attrs.reduce<string[][]>(
(acc, attr) => {
const next: string[][] = [];
const values = attr.values.filter((v) => v.name?.trim());
for (const prev of acc) {
for (const v of values) {
const vid = v.id || `__new__:${attr.name}:${v.name}`;
next.push([...prev, vid]);
}
}
return next.length ? next : acc;
},
[[]],
);
}
function keyOf(ids: string[]) {
return [...ids].sort().join('_');
}
type Props = {
productId: string;
initialAttrs: SpecAttr[];
initialSkus: SkuRow[];
onSaved: () => void;
};
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
const [attrs, setAttrs] = useState<SpecAttr[]>(
initialAttrs.length
? initialAttrs
: [],
);
const [skus, setSkus] = useState<SkuRow[]>(
initialSkus.length
? initialSkus.map((s) => ({
...s,
specValueIds: s.specValueIds ?? [],
status: s.status || 'ON_SALE',
allowOnlinePurchase: s.allowOnlinePurchase !== false,
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
allowOnSitePickup: !!s.allowOnSitePickup,
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
isDefault: !!s.isDefault,
imageUrl: s.imageUrl ?? '',
}))
: [
{
specValueIds: [],
barcode69: '',
price: 0,
status: 'ON_SALE',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
imageUrl: '',
},
],
);
const [saving, setSaving] = useState(false);
const [editIndex, setEditIndex] = useState<number | null>(null);
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
function regenerateMatrix() {
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
const next: SkuRow[] = combos.map((ids, i) => {
const existing = byKey.get(keyOf(ids));
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
return {
specValueIds: ids,
barcode69: '',
price: skus[0]?.price ?? 0,
benefitAmount: skus[0]?.benefitAmount,
status: 'DRAFT',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: i === 0,
sortOrder: i,
imageUrl: '',
};
});
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
setSkus(next.length ? next : skus);
}
function patchSku(index: number, patch: Partial<SkuRow>) {
setSkus((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...patch };
return next;
});
}
async function handleSave() {
setSaving(true);
try {
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
const specsRes = await request<{
specAttrs: SpecAttr[];
skus: SkuRow[];
}>(`/admin/products/${productId}/specs`, {
method: 'PUT',
body: JSON.stringify({
attrs: attrs.map((a, i) => ({
id: a.id,
name: a.name.trim(),
sortOrder: i,
values: a.values
.filter((v) => v.name.trim())
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
})),
}),
});
// 映射临时 id → 真实 id
const nameToId = new Map<string, string>();
for (const a of specsRes.specAttrs ?? []) {
for (const v of a.values ?? []) {
nameToId.set(`${a.name}:${v.name}`, v.id!);
}
}
const resolveIds = (ids: string[]) =>
ids.map((id) => {
if (!id.startsWith('__new__:')) return id;
const [, attrName, valueName] = id.split(':');
const real = nameToId.get(`${attrName}:${valueName}`);
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
return real;
});
const payloadSkus = skus.map((s, i) => ({
id: s.id,
specValueIds: resolveIds(s.specValueIds ?? []),
barcode69: s.barcode69.trim(),
price: s.price,
benefitAmount: s.benefitAmount,
status: s.status,
allowOnlinePurchase: s.allowOnlinePurchase,
allowCrossCityDelivery: s.allowCrossCityDelivery,
allowOnSitePickup: s.allowOnSitePickup,
saleUnit: s.saleUnit,
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
isDefault: !!s.isDefault,
sortOrder: i,
imageUrl: s.imageUrl?.trim() || null,
}));
const barcodes = payloadSkus.map((row) => row.barcode69);
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
if (new Set(barcodes).size !== barcodes.length) {
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
}
await request(`/admin/products/${productId}/skus`, {
method: 'PUT',
body: JSON.stringify({ skus: payloadSkus }),
});
message.success('规格与 SKU 已保存');
onSaved();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
const editing = editIndex != null ? skus[editIndex] : null;
return (
<div>
<Typography.Paragraph type="secondary">
SKU DK <strong></strong> 69
SKU
</Typography.Paragraph>
<Typography.Title level={5}></Typography.Title>
{attrs.map((attr, ai) => (
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Space align="start" style={{ width: '100%' }} wrap>
<Input
style={{ width: 140 }}
placeholder="规格名"
value={attr.name}
onChange={(e) => {
const next = [...attrs];
next[ai] = { ...attr, name: e.target.value };
setAttrs(next);
}}
/>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
/>
</Space>
<div style={{ marginTop: 8 }}>
{attr.values.map((val, vi) => (
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
<Input
placeholder="规格值"
value={val.name}
onChange={(e) => {
const next = [...attrs];
const values = [...attr.values];
values[vi] = { ...val, name: e.target.value };
next[ai] = { ...attr, values };
setAttrs(next);
}}
/>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => {
const next = [...attrs];
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
setAttrs(next);
}}
/>
</Space>
))}
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={() => {
const next = [...attrs];
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
setAttrs(next);
}}
>
</Button>
</div>
</div>
))}
<Space style={{ marginBottom: 16 }}>
<Button
icon={<PlusOutlined />}
disabled={attrs.length >= 3}
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
>
</Button>
<Button onClick={regenerateMatrix}> SKU </Button>
</Space>
<Typography.Title level={5}>SKU </Typography.Title>
<Table
size="small"
rowKey={(_, i) => String(i)}
pagination={false}
scroll={{ x: 1240 }}
dataSource={skus}
columns={[
{
title: '规格',
width: 140,
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
},
{
title: 'SKU',
width: 110,
render: (_, row) => (
<Typography.Text type="secondary">
{row.skuCode || '保存后自动生成'}
</Typography.Text>
),
},
{
title: <span>69 <Typography.Text type="danger">*</Typography.Text></span>,
width: 180,
render: (_, row, index) => (
<Input
placeholder="本规格独立 69 码"
value={row.barcode69}
onChange={(e) => {
const next = [...skus];
next[index] = { ...row, barcode69: e.target.value };
setSkus(next);
}}
/>
),
},
{
title: '售价',
width: 100,
render: (_, row, index) => (
<InputNumber
min={0}
style={{ width: '100%' }}
value={row.price}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, price: Number(v) || 0 };
setSkus(next);
}}
/>
),
},
{
title: '权益',
width: 100,
render: (_, row, index) => (
<InputNumber
min={0}
style={{ width: '100%' }}
value={row.benefitAmount}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
setSkus(next);
}}
/>
),
},
{
title: '状态',
width: 110,
render: (_, row, index) => (
<Select
style={{ width: '100%' }}
value={row.status}
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={(status) => {
const next = [...skus];
next[index] = { ...row, status };
setSkus(next);
}}
/>
),
},
{
title: '单位',
width: 160,
render: (_, row, index) => (
<Space>
<Select
style={{ width: 80 }}
value={row.saleUnit}
options={[
{ value: 'BOTTLE', label: '瓶' },
{ value: 'BOX', label: '箱' },
]}
onChange={(saleUnit) => {
const next = [...skus];
next[index] = {
...row,
saleUnit,
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
};
setSkus(next);
}}
/>
{row.saleUnit === 'BOX' ? (
<InputNumber
min={1}
style={{ width: 70 }}
value={row.bottlesPerUnit}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
setSkus(next);
}}
/>
) : null}
</Space>
),
},
{
title: '履约',
width: 200,
render: (_, row, index) => (
<Space direction="vertical" size={0}>
<Switch
checkedChildren="线上"
unCheckedChildren="线上"
checked={row.allowOnlinePurchase}
onChange={(checked) => {
const next = [...skus];
next[index] = {
...row,
allowOnlinePurchase: checked,
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
};
setSkus(next);
}}
/>
<Switch
checkedChildren="跨城"
unCheckedChildren="跨城"
disabled={!row.allowOnlinePurchase}
checked={row.allowCrossCityDelivery}
onChange={(checked) => {
const next = [...skus];
next[index] = { ...row, allowCrossCityDelivery: checked };
setSkus(next);
}}
/>
<Switch
checkedChildren="现场"
unCheckedChildren="现场"
checked={row.allowOnSitePickup}
onChange={(checked) => {
const next = [...skus];
next[index] = { ...row, allowOnSitePickup: checked };
setSkus(next);
}}
/>
</Space>
),
},
{
title: '默认',
width: 70,
render: (_, row, index) => (
<Radio
checked={row.isDefault}
onChange={() => {
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
}}
/>
),
},
{
title: '主图',
width: 72,
render: (_, row) =>
row.imageUrl ? (
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '编辑',
width: 80,
fixed: 'right',
render: (_, row, index) => (
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
</Button>
),
},
]}
/>
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
SKU
</Button>
<Modal
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
open={editIndex != null}
onCancel={() => setEditIndex(null)}
onOk={() => setEditIndex(null)}
okText="完成"
width={560}
destroyOnClose
>
{editing && editIndex != null ? (
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<div>
<Typography.Text type="secondary">SKU</Typography.Text>
<div>{editing.skuCode || '保存后自动生成'}</div>
</div>
<div>
<Typography.Text>69 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
<Input
placeholder="本规格独立 69 码"
value={editing.barcode69}
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
/>
</div>
<div>
<Typography.Text></Typography.Text>
<OssUpload
bizType="COVER"
value={editing.imageUrl || ''}
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
/>
</div>
<Space>
<div>
<Typography.Text></Typography.Text>
<InputNumber
min={0}
style={{ width: 140 }}
value={editing.price}
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
/>
</div>
<div>
<Typography.Text></Typography.Text>
<InputNumber
min={0}
style={{ width: 140 }}
value={editing.benefitAmount}
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
/>
</div>
</Space>
<div>
<Typography.Text></Typography.Text>
<Select
style={{ width: '100%' }}
value={editing.status}
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={(status) => patchSku(editIndex, { status })}
/>
</div>
<Space>
<Select
style={{ width: 100 }}
value={editing.saleUnit}
options={[
{ value: 'BOTTLE', label: '瓶' },
{ value: 'BOX', label: '箱' },
]}
onChange={(saleUnit) =>
patchSku(editIndex, {
saleUnit,
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
})
}
/>
{editing.saleUnit === 'BOX' ? (
<InputNumber
min={1}
value={editing.bottlesPerUnit}
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
/>
) : null}
</Space>
<Space>
<Switch
checkedChildren="线上"
unCheckedChildren="线上"
checked={editing.allowOnlinePurchase}
onChange={(checked) =>
patchSku(editIndex, {
allowOnlinePurchase: checked,
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
})
}
/>
<Switch
checkedChildren="跨城"
unCheckedChildren="跨城"
disabled={!editing.allowOnlinePurchase}
checked={editing.allowCrossCityDelivery}
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
/>
<Switch
checkedChildren="现场"
unCheckedChildren="现场"
checked={editing.allowOnSitePickup}
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
/>
<Switch
checkedChildren="默认"
unCheckedChildren="默认"
checked={editing.isDefault}
onChange={(checked) => {
if (!checked) return;
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
}}
/>
</Space>
</Space>
) : null}
</Modal>
</div>
);
}
@@ -0,0 +1,484 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Form,
Input,
InputNumber,
Modal,
QRCode,
Radio,
Select,
Space,
Typography,
message,
} from 'antd';
import type {
HqProxyOrderCreateRequest,
PartnerProxyDeliveryMode,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
ProxyOrderCreateResponse,
ProxyOrderPayResponse,
} from '@dukang/shared-types';
import ChinaRegionCascader from './ChinaRegionCascader';
import { parseRegionCodes } from '../lib/china-region';
import { request } from '../lib/api';
type ProxyOrderModalProps = {
open: boolean;
onClose: () => void;
onSuccess: (order: { id: string; orderNo: string }) => void;
};
type PayStatus = {
id: string;
orderNo: string;
status: string;
payStatus: string;
};
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) {
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [phone, setPhone] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState<string>();
const [skuId, setSkuId] = useState<string>();
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [step, setStep] = useState<'form' | 'pay'>('form');
const [created, setCreated] = useState<ProxyOrderCreateResponse | null>(null);
const [codeUrl, setCodeUrl] = useState<string | null>(null);
const [payLoading, setPayLoading] = useState(false);
const pollRef = useRef<number | null>(null);
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
function stopPoll() {
if (pollRef.current != null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}
useEffect(() => {
if (!open) return;
setLoadingOptions(true);
request<PartnerProxyOrderOptions>('/admin/proxy-orders/options')
.then((data) => {
setOptions(data);
if (data.products[0]) setProductId(data.products[0].id);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false));
}, [open]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const selectedSku =
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(() => {
if (!open || !selectedProduct) return;
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
setDeliveryMode('ON_SITE_PICKUP');
return;
}
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
setDeliveryMode('ADDRESS');
}
}, [open, selectedProduct, allowOnline, allowOnSite, deliveryMode]);
useEffect(() => {
if (!open || !productId || quantity < 1 || step !== 'form') {
if (step === 'form') setPreview(null);
return;
}
const timer = window.setTimeout(() => {
setPreviewLoading(true);
request<PartnerProxyOrderPreviewResult>('/admin/proxy-orders/preview', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
})
.then(setPreview)
.catch(() => setPreview(null))
.finally(() => setPreviewLoading(false));
}, 300);
return () => window.clearTimeout(timer);
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
useEffect(() => () => stopPoll(), []);
function resetForm() {
stopPoll();
setPhone('');
setReceiverName('');
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
setProductId(undefined);
setSkuId(undefined);
setPromoCodeId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
setPreview(null);
setProductId(options?.products[0]?.id);
setStep('form');
setCreated(null);
setCodeUrl(null);
setPayLoading(false);
}
function handleClose() {
resetForm();
onClose();
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!allowOnline) return '该商品不支持线上购买';
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else if (!allowOnSite) {
return '该商品不支持现场提货';
}
if (!preview) return '请等待费用计算完成';
return null;
}
function startPoll(orderId: string) {
stopPoll();
pollRef.current = window.setInterval(() => {
void request<PayStatus>(`/admin/proxy-orders/${orderId}/pay-status`)
.then((st) => {
if (st.payStatus === 'PAID') {
stopPoll();
message.success(`支付成功:${st.orderNo}`);
const payload = { id: st.id, orderNo: st.orderNo };
resetForm();
onSuccess(payload);
}
})
.catch(() => undefined);
}, 2000);
}
async function submit() {
const err = validateForm();
if (err) {
message.warning(err);
return;
}
const payload: HqProxyOrderCreateRequest = {
phone: phone.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined,
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId: productId!,
quantity,
promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
};
setSubmitting(true);
try {
const order = await request<ProxyOrderCreateResponse>('/admin/proxy-orders', {
method: 'POST',
body: JSON.stringify(payload),
});
setCreated(order);
setStep('pay');
setPayLoading(true);
const pay = await request<ProxyOrderPayResponse>(`/admin/proxy-orders/${order.id}/pay`, {
method: 'POST',
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);
startPoll(order.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '下单失败');
setStep('form');
setCreated(null);
} finally {
setSubmitting(false);
setPayLoading(false);
}
}
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return (
<Modal
title={step === 'pay' ? '代下单 · 待支付' : '代下单'}
open={open}
onCancel={handleClose}
width={640}
destroyOnClose
footer={
step === 'pay' ? (
<Space>
<Button onClick={handleClose}></Button>
</Space>
) : (
<Space>
<Button onClick={handleClose}></Button>
<Button type="primary" loading={submitting || loadingOptions} onClick={() => void submit()}>
</Button>
</Space>
)
}
>
{step === 'pay' && created ? (
<div style={{ textAlign: 'center' }}>
<Alert
type="info"
showIcon
style={{ marginBottom: 16, textAlign: 'left' }}
message={`订单 ${created.orderNo} 待支付 ¥${fmtMoney(created.payAmount)},请扫码支付`}
description={
created.deliveryType === 'ON_SITE_PICKUP'
? '支付完成后订单即为已完成,并发放好客权益'
: '支付完成后将进入待发货,由总部履约发货'
}
/>
{payLoading && !codeUrl ? (
<Typography.Text type="secondary"></Typography.Text>
) : codeUrl ? (
<Space direction="vertical" size={12} align="center">
<QRCode value={codeUrl} size={200} />
<Typography.Text type="secondary">使</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
{codeUrl}
</Typography.Text>
</Space>
) : (
<Typography.Text type="danger"></Typography.Text>
)}
</div>
) : (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="提交后生成微信收款码,支付成功后发放好客权益;配送单进入待发货"
/>
<Form layout="vertical">
<Form.Item label="用户手机号" required>
<Input
placeholder="11 位手机号"
value={phone}
maxLength={11}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
</Form.Item>
<Form.Item label="酒品" required>
<Select
showSearch
optionFilterProp="label"
loading={loadingOptions}
placeholder="选择商品"
value={productId}
onChange={(id) => {
setProductId(id);
setSkuId(undefined);
}}
options={(options?.products ?? []).map((p) => ({
value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
}))}
/>
</Form.Item>
{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
min={1}
value={quantity}
onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))}
style={{ width: '100%' }}
/>
</Form.Item>
{(options?.promoCodes.length ?? 0) > 0 ? (
<Form.Item label="绑定推广码(选填)">
<Select
allowClear
placeholder="不绑定"
value={promoCodeId}
onChange={setPromoCodeId}
options={(options?.promoCodes ?? []).map((p) => ({
value: p.id,
label: `${p.code} · ${p.name}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label="履约方式" required>
{allowOnline && allowOnSite ? (
<Radio.Group
value={deliveryMode}
onChange={(e) => {
setDeliveryMode(e.target.value);
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
}}
options={[
{ value: 'ADDRESS', label: '配送到址' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
]}
/>
) : (
<Typography.Text type="secondary">
{!allowOnline && allowOnSite
? '该商品仅支持现场提货'
: allowOnline && !allowOnSite
? allowCrossCity
? '该商品仅支持配送到址(含跨城)'
: '该商品仅支持配送到址(不可跨城)'
: '该商品暂无可选履约方式'}
</Typography.Text>
)}
</Form.Item>
{deliveryMode === 'ADDRESS' && allowOnline ? (
<>
<Form.Item label="收货人(选填)">
<Input
placeholder="默认:用户+手机尾号"
value={receiverName}
maxLength={32}
onChange={(e) => setReceiverName(e.target.value)}
/>
</Form.Item>
<Form.Item label="省市区" required>
<ChinaRegionCascader value={regionCodes} onChange={setRegionCodes} />
</Form.Item>
<Form.Item label="详细地址" required>
<Input.TextArea
rows={2}
placeholder="街道门牌等"
value={addressDetail}
maxLength={256}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)}>
</Checkbox>
</Form.Item>
</>
) : null}
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>
<Typography.Text type="secondary"></Typography.Text>
{previewLoading ? (
<div></div>
) : preview ? (
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 8 }}>
<div>{deliveryLabel}</div>
<div>¥{fmtMoney(preview.productAmount)}</div>
<div>¥{fmtMoney(preview.payAmount)}</div>
<div>¥{fmtMoney(preview.benefitAmount)}</div>
</Space>
) : (
<div style={{ marginTop: 8 }}>
<Typography.Text type="secondary"></Typography.Text>
</div>
)}
</div>
</Form>
</>
)}
</Modal>
);
}
@@ -0,0 +1,84 @@
import { Descriptions } from 'antd';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? '—';
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
type Props = {
detail: Record<string, unknown>;
/** 列表页已脱敏展示时可设为 false,详情抽屉展示完整手机号 */
maskUserPhone?: boolean;
};
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
const user = detail.user as
| { userNo?: string; nickname?: string | null; phone?: string | null }
| undefined;
const store = detail.store as
| {
name?: string;
cityName?: string;
address?: string;
partnerAccount?: { companyName?: string };
}
| undefined;
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
const channel = detail.channel === 'PHONE' ? 'PHONE' : 'SCAN';
return (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(detail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="方式">{REDEEM_CHANNEL_LABELS[channel as RedeemChannel]}</Descriptions.Item>
<Descriptions.Item label="核销额">¥{Number(detail.amount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
<Descriptions.Item label="用户手机">
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
</Descriptions.Item>
<Descriptions.Item label="门店">
{store?.name ?? '—'}
{store?.cityName ? `${store.cityName}` : ''}
</Descriptions.Item>
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="券分摊">
{(
detail.allocations as Array<{
couponNo?: string;
orderNo?: string | null;
amount?: number;
}>
)
.map((a, idx) => {
const role = idx === 0 ? '主' : '次';
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
a.orderNo ? `(订单 ${a.orderNo}` : ''
}`;
})
.join('')}
</Descriptions.Item>
) : null}
{detail.payout ? (
<Descriptions.Item label="门店结算单">
¥{Number((detail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
{' / '}
{String((detail.payout as { status?: string }).status ?? '—')}
</Descriptions.Item>
) : null}
{detail.rating ? (
<Descriptions.Item label="评价">
{(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '}
{(detail.rating as { envScore?: number }).envScore ?? '—'}
</Descriptions.Item>
) : null}
</Descriptions>
);
}
@@ -0,0 +1,453 @@
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,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
change?: keyof typeof CHANGE_LABELS;
changes?: FieldChange[];
}) {
const images = normalizeStorePackageImageUrls(pkg);
const meta = change ? CHANGE_LABELS[change] : null;
return (
<div
style={{
marginBottom: 12,
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
<Space style={{ marginBottom: 8 }} wrap>
{title ? <Typography.Text type="secondary">{title}</Typography.Text> : null}
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
</Space>
<div style={{ marginBottom: 8 }}>
<strong>{pkg.name}</strong>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
</div>
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 4 }}>
{pkg.usableTime}
</Typography.Paragraph>
) : null}
{pkg.otherNotes ? (
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.otherNotes}
</Typography.Paragraph>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image key={url} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
{changes && changes.length ? (
<div
style={{
marginTop: 8,
padding: 8,
background: '#fff7e6',
border: '1px solid #ffe7ba',
borderRadius: 6,
}}
>
<Typography.Text strong style={{ fontSize: 12 }}>
</Typography.Text>
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{changes.map((c) => (
<li key={c.label} style={{ marginBottom: 6 }}>
<Typography.Text type="secondary">{c.label}</Typography.Text>
{c.kind === 'text' ? (
<TextDiff oldText={c.old} newText={c.now} />
) : (
<>
<Typography.Text delete type="secondary">
{c.old}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>{c.now}</Typography.Text>
</>
)}
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
export type StorePackageAuditPanelProps = {
requestId: string;
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
showActions?: boolean;
onAudited?: () => void;
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
};
/** 套餐变更对比 + 通过/驳回(审核通知页与门店详情抽屉共用) */
export default function StorePackageAuditPanel({
requestId,
showActions = true,
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))}
/>
))
) : (
<Typography.Text type="secondary">线</Typography.Text>
)}
</div>
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{(detail.packages ?? []).length ? (
(detail.packages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`pending-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</div>
<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();
}
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
import { request } from '../lib/api';
import {
placeToPicked,
type LbsPlaceItem,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
type Props = {
open: boolean;
onClose: () => void;
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
/** 城市名,提升搜索相关性 */
region?: string | null;
};
function hasCoords(lat: unknown, lng: unknown): lat is number {
const a = typeof lat === 'number' ? lat : Number(lat);
const b = typeof lng === 'number' ? lng : Number(lng);
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
}
export default function TencentLocPickerModal({
open,
onClose,
onPick,
latitude,
longitude,
region,
}: Props) {
const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
const seqRef = useRef(0);
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setPending(null);
setHint('输入地点名称搜索,或加载附近地点');
return;
}
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (hasCoords(lat, lng)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function loadNearby(lat: number, lng: number) {
const seq = ++seqRef.current;
setLoading(true);
setHint('正在加载附近地点…');
try {
const res = await request<{ items: LbsPlaceItem[] }>(
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '加载附近地点失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) {
message.warning('请输入搜索关键词');
return;
}
const seq = ++seqRef.current;
setLoading(true);
setHint('搜索中…');
try {
const params = new URLSearchParams({ keyword: trimmed });
if (region?.trim()) params.set('region', region.trim());
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
params.set('lat', String(lat));
params.set('lng', String(lng));
}
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '搜索失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
message.error('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
void loadNearby(lat, lng);
},
() => {
setLoading(false);
message.error('定位失败,请检查浏览器定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
function confirmPick() {
if (!pending) {
message.warning('请先从列表中选择一个地点');
return;
}
onPick(pending);
onClose();
}
return (
<Modal
title="地图选点(腾讯位置服务)"
open={open}
onCancel={onClose}
width={720}
destroyOnClose
footer={
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
{pending
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
pending.name ? ` · ${pending.name}` : ''
}`
: '搜索或选择附近地点后确认'}
</Typography.Text>
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" disabled={!pending} onClick={confirmPick}>
</Button>
</Space>
</Space>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space.Compact style={{ width: '100%' }}>
<Input
allowClear
placeholder="输入小区 / 写字楼 / 门店名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => void runSearch(keyword)}
/>
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
</Button>
</Space.Compact>
<Space wrap>
<Button onClick={useBrowserLocation} disabled={loading}>
</Button>
<Typography.Text type="secondary">{hint}</Typography.Text>
</Space>
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
{loading && !items.length ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin tip="加载中…" />
</div>
) : items.length ? (
<List
size="small"
dataSource={items}
renderItem={(item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<List.Item
style={{
cursor: 'pointer',
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
paddingInline: 12,
}}
onClick={() => setPending(placeToPicked(item))}
>
<List.Item.Meta
title={item.title}
description={
<span>
{item.address}
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</Typography.Text>
</span>
}
/>
</List.Item>
);
}}
/>
) : (
<Empty style={{ marginTop: 80 }} description={hint} />
)}
</div>
</Space>
</Modal>
);
}
+231 -9
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd';
import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
import type { MenuProps } from 'antd';
import {
RobotOutlined,
DashboardOutlined,
UserOutlined,
ShoppingOutlined,
@@ -17,12 +18,17 @@ import {
LockOutlined,
SettingOutlined,
AccountBookOutlined,
ProjectOutlined,
} from '@ant-design/icons';
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
const { Header, Sider, Content } = Layout;
type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' },
@@ -38,12 +44,26 @@ const MENU_ITEMS: MenuProps['items'] = [
},
{ 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: '门店资源' },
],
@@ -56,6 +76,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
],
},
{
@@ -66,6 +87,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
],
},
{
@@ -89,7 +111,26 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
],
},
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
{
key: 'tickets-group',
icon: <CarOutlined />,
label: '工单',
children: [
{ key: '/tickets', label: '工单中心' },
{ key: '/tickets/support', label: '技术支持' },
],
},
{
key: 'dev-plan-group',
icon: <ProjectOutlined />,
label: '开发计划',
children: [
{ key: '/dev-plan/versions', label: '版本列表' },
{ key: '/dev-plan/tasks', label: '任务列表' },
{ key: '/dev-plan/settings', label: '开发设置' },
],
},
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{
key: 'logs-group',
@@ -101,23 +142,169 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/logs/partners', label: '合伙人日志' },
{ key: '/logs/hq', label: 'HQ 操作日志' },
{ key: '/logs/third-party', label: '第三方日志' },
{ key: '/logs/domain-events', label: '领域事件' },
{ key: '/logs/wecom-bots', label: '智能机器人日志' },
],
},
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
];
function menuAllowed(key: string, permissionKeys: string[]): boolean {
if (key === 'tickets-group') {
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
}
if (key === 'dev-plan-group') {
return permissionKeys.includes('dev_plan');
}
const map: Record<string, string | 'system_settings_any'> = {
'/': 'dashboard',
'/users': 'users',
'/wechat-bindings': 'wechat_bindings',
'products-group': 'products',
'/products': 'products',
'/product-detail-templates': 'products',
'/orders': 'orders',
'/promo-codes': 'promo_codes',
'/wecom/bots': 'wecom_bots',
'/wecom/pushes': 'wecom_bots',
'wecom-group': 'wecom_bots',
'/llm-configs': 'llm_configs',
'/knowledge-bases': 'knowledge_bases',
'stores-group': 'stores',
'/stores': 'stores',
'/store-package-audits': 'stores',
'/store-ratings': 'stores',
'/store-categories': 'stores',
'/store-accounts': 'stores',
'/store-media': 'stores',
'partners-group': 'partners',
'/cities': 'partners',
'/city-partners': 'partners',
'/city-warehouses': 'partners',
'/fulfillment-providers': 'partners',
'finance-group': 'finance',
'/finance/store-bills': 'finance',
'/finance/store-withdrawals': 'finance',
'/finance/partner-bills': 'finance',
'/finance/winery-bills': 'finance',
'/finance/logistics-bills': 'finance',
'benefit-group': 'benefit',
'/benefit/coupons': 'benefit',
'/benefit/ledgers': 'benefit',
'/redeem-records': 'benefit',
'/redeem-pending': 'benefit',
'/redeem/debug': 'benefit',
'deliveries-group': 'deliveries',
'/deliveries': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries',
'/tickets': 'tickets',
'/tickets/support': 'tech_support',
'dev-plan-group': 'dev_plan',
'/dev-plan/versions': 'dev_plan',
'/dev-plan/tasks': 'dev_plan',
'/dev-plan/settings': 'dev_plan',
'/invoices': 'invoices',
'/resources': 'resources',
'logs-group': 'logs',
'/logs/users': 'logs',
'/logs/wecom-bots': 'logs',
'/logs/stores': 'logs',
'/logs/partners': 'logs',
'/logs/hq': 'logs',
'/logs/third-party': 'logs',
'/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions',
'/test-whitelist': 'test_whitelist',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
};
const need = map[key];
if (!need) return true;
if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys);
return permissionKeys.includes(need);
}
function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] {
if (!items) return items;
return items
.map((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) return item;
const key = String(item.key);
if ('children' in item && Array.isArray(item.children)) {
if (!menuAllowed(key, permissionKeys)) return null;
const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys);
if (!children?.length) return null;
return { ...item, children } as MenuItem;
}
return menuAllowed(key, permissionKeys) ? item : null;
})
.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';
export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
const contentRef = useRef<HTMLDivElement>(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(() => {
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(() => {
contentRef.current?.scrollTo({ top: 0, left: 0 });
}, [location.pathname]);
@@ -135,26 +322,53 @@ export default function AdminLayout() {
? '/promo-codes'
: location.pathname;
const menuItems = useMemo(() => {
const base =
!profile || profile.adminRole === 'SUPER_ADMIN'
? MENU_ITEMS
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
return attachAuditBadge(base, auditPendingCount);
}, [profile, auditPendingCount]);
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
{IS_STAGING ? (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1100,
background: '#d48806',
color: '#fff',
textAlign: 'center',
fontSize: 13,
fontWeight: 600,
lineHeight: '28px',
}}
>
· staging · · Mock
</div>
) : null}
<Sider
breakpoint="lg"
collapsedWidth={64}
theme="dark"
width={220}
style={{ height: '100vh', overflow: 'hidden' }}
style={{ height: '100vh', overflow: 'hidden', marginTop: IS_STAGING ? 28 : 0 }}
>
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<div style={{ display: 'flex', flexDirection: 'column', height: IS_STAGING ? 'calc(100vh - 28px)' : '100vh' }}>
<div style={{ flexShrink: 0, padding: '16px', color: '#fff', fontWeight: 600 }}>
HQ
HQ{IS_STAGING ? ' · 测试' : ''}
</div>
<div className="admin-sider-menu-scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
items={MENU_ITEMS}
defaultOpenKeys={[]}
items={menuItems}
onClick={({ key }) => {
if (key.startsWith('/')) navigate(key);
}}
@@ -162,7 +376,15 @@ export default function AdminLayout() {
</div>
</div>
</Sider>
<Layout style={{ height: '100vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Layout
style={{
height: IS_STAGING ? 'calc(100vh - 28px)' : '100vh',
marginTop: IS_STAGING ? 28 : 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Header
style={{
flexShrink: 0,
+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();
}
+89 -1
View File
@@ -7,6 +7,7 @@ export type HqProfile = {
name: string;
adminRole: string;
status: string;
permissionKeys?: string[];
};
export function getToken() {
@@ -79,9 +80,61 @@ export type DashboardStats = {
partnersTotal: number;
redeemToday: number;
deliveriesTotal: number;
pendingPayouts?: number;
/** 合伙人已确认、待总部审核打款 */
pendingBills?: number;
pendingPartnerDraftBills?: number;
openTickets?: number;
pendingStoreWithdrawals?: number;
overdueStoreWithdrawals?: number;
ordersByStatus: Array<{ status: string; count: number }>;
};
export type DashboardAnalytics = {
summary: {
users: number;
orders: number;
payingUsers: number;
partners: number;
stores: number;
redeems: number;
redeemAmount: number;
};
byDate: Array<{
date: string;
users: number;
orders: number;
partners: number;
stores: number;
redeems: number;
redeemAmount: number;
}>;
byCity: Array<{
cityId: string;
cityName: string;
users: number;
orders: number;
partners: number;
stores: number;
redeems: number;
redeemAmount: number;
}>;
byPromo: Array<{
promoCodeId: string | null;
code: string;
name: string;
users: number;
orders: number;
}>;
byPartner: Array<{
partnerAccountId: string;
companyName: string;
stores: number;
redeems: number;
redeemAmount: number;
}>;
};
export type SystemVersion = {
id: string;
gitTag: string | null;
@@ -114,6 +167,21 @@ export type AdminUserRow = {
sourceLabel: string | null;
createdAt: string;
orderCount: number;
isTest?: boolean;
/** 好客权益·累计获得(含已使用,不含退款作废) */
benefitTotalAmount?: number;
/** 好客权益·已使用(已核销) */
benefitUsedAmount?: number;
/** 好客权益·剩余未使用 */
benefitBalance?: number;
};
export type AdminOrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
export type AdminOrderRow = {
@@ -122,9 +190,29 @@ export type AdminOrderRow = {
status: string;
deliveryType: string;
payAmount: number;
productName?: string;
productSpec?: string;
quantity?: number;
receiverName: string;
receiverPhone: string;
createdAt: string;
cityId?: string;
city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: string } | null;
fulfillmentHold?: boolean;
fulfillmentHoldReason?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
isTest?: boolean;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null };
delivery?: {
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
logisticsCompany?: string | null;
manualQueryUrl?: string | null;
};
};
+33
View File
@@ -79,6 +79,39 @@ export function districtCodeToCityCode(districtCode: string): string {
return `${districtCode.slice(0, 4)}00`;
}
export type DistrictOption = { value: string; label: string };
/** 按开城城市 code(4 位或 6 位)取该市下区县列表 */
export function getDistrictOptionsByCityCode(cityCode?: string | null): DistrictOption[] {
if (!cityCode?.trim()) return [];
const normalized = normalizeCityAdcode(cityCode.trim());
const prefix4 = normalized.slice(0, 4);
for (const province of regionData) {
for (const city of province.children ?? []) {
const cityValue = String(city.value);
const cityNorm = normalizeCityAdcode(cityValue);
if (cityNorm === normalized || cityValue === prefix4 || cityValue === cityCode.trim()) {
return (city.children ?? []).map((d) => ({
value: String(d.value),
label: String(d.label),
}));
}
}
}
return [];
}
export function districtCodeLabel(code: string): string {
return codeToText[code] || code;
}
/** 列表展示:区县码 → 中文名,顿号拼接 */
export function formatDistrictLabels(codes?: string[] | null): string {
if (!codes?.length) return '—';
return codes.map((c) => districtCodeLabel(String(c))).join('、');
}
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
if (!codes || codes.length < 3) return null;
const [provinceCode, cityCode, districtCode] = codes;
+52 -5
View File
@@ -19,6 +19,17 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
REFUNDED: '已退款',
};
/** 订单状态流转操作人(common_event.param3 */
export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
USER: '用户',
USER_ON_SITE: '用户·现场取货',
USER_WECHAT_CONFIRM: '用户·微信确认收货',
WECHAT_TRADE_MANAGE: '微信结算同步',
MOCK: 'Mock',
MOCK_PAY: 'Mock 支付',
SYSTEM: '系统',
};
/** 订单状态 Tag 颜色(Ant Design preset */
export const ORDER_STATUS_COLORS: Record<string, string> = {
PENDING_PAY: 'red',
@@ -34,8 +45,8 @@ export const ORDER_STATUS_COLORS: Record<string, string> = {
export const STORE_STATUS_LABELS: Record<string, string> = {
OPEN: '营业中',
PAUSED: '暂停',
CLOSED: '关闭',
PAUSED: '临时闭店',
CLOSED: '永久关闭',
};
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
@@ -62,9 +73,11 @@ export const CITY_STATUS_LABELS: Record<string, string> = {
};
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
CONFIRMED: '确认',
PAID: '已结算',
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
};
export const MEDIA_TYPE_LABELS: Record<string, string> = {
@@ -126,3 +139,37 @@ export const LEDGER_TYPE_LABELS: Record<string, string> = {
export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—';
}
/** 联系电话脱敏:手机 138****5678;座机 0379-****888;空值返回 — */
export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim().replace(/\s+/g, '');
if (!raw) return '—';
if (/^1[3-9]\d{9}$/.test(raw)) {
const digits = raw.replace(/\D/g, '');
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
const extMatch = raw.match(/-(\d{1,6})$/);
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
const ext = hasExt ? extMatch![1] : '';
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
const digits = main.replace(/\D/g, '');
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
const area = digits.slice(0, areaLen);
const local = digits.slice(areaLen);
const keepTail = Math.min(4, Math.max(2, local.length - 4));
const maskedLocal =
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
const joiner = main.includes('-') ? '-' : '';
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
}
const digits = raw.replace(/\D/g, '');
if (digits.length >= 11) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (digits.length >= 7) {
return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
}
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
return '****';
}
+21
View File
@@ -27,6 +27,11 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
{ value: 'PRODUCT_CREATE', label: '新增商品' },
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
{ value: 'PRODUCT_DELETE', label: '删除商品' },
@@ -34,11 +39,27 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
{ value: 'TICKET_APPROVE', label: '工单通过' },
{ value: 'TICKET_REJECT', label: '工单驳回' },
{ value: 'TICKET_CREATE', label: '创建工单' },
{ value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' },
{ value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' },
{ value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' },
{ value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' },
{ value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' },
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
{ value: 'INVOICE_ISSUE', label: '开具发票' },
{ value: 'INVOICE_REJECT', label: '驳回发票' },
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
{ value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' },
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
{ value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' },
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
] as const;
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
+71 -3
View File
@@ -1,3 +1,5 @@
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
export type StoreCreateForm = {
partnerAccountId: string;
cityId: string;
@@ -6,35 +8,101 @@ export type StoreCreateForm = {
city?: string;
district: string;
districtCode?: string;
categoryParentId?: string;
categoryId: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
intro?: string;
benefitUsageRule?: string;
/** 对外联系电话(店长);可与登录号不同,支持座机 */
contactPhone?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
closeTime2?: string;
avgPrice?: number | null;
coverUrl?: string;
envPhotoUrls?: string[];
contractUrl?: string;
/** 签约合同,支持多张照片 / PDF */
contractUrls?: string[];
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
settlementRate?: number;
sortOrder?: number;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
};
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
export function validateStoreCreateStep1(
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
form: Pick<
StoreCreateForm,
| 'partnerAccountId'
| 'cityId'
| 'regionCodes'
| 'categoryId'
| 'name'
| 'phone'
| 'address'
| 'intro'
| 'benefitUsageRule'
| 'openTime'
| 'closeTime'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'contactPhone'
>,
): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
const contactPhone = form.contactPhone?.trim();
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
if (!form.address?.trim()) return '请填写详细地址';
const openTime = String(form.openTime || '').trim();
const closeTime = String(form.closeTime || '').trim();
if (!openTime || !closeTime) return '请填写营业时间';
if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM';
if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间';
const openTime2 = String(form.openTime2 || '').trim();
const closeTime2 = String(form.closeTime2 || '').trim();
if (openTime2 || closeTime2) {
if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间';
if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM';
if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间';
if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间';
}
if (form.avgPrice != null && String(form.avgPrice).trim() !== '') {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (form.intro?.trim()) {
const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
}
return null;
}
@@ -0,0 +1,26 @@
export type TencentPickedLocation = {
latitude: number;
longitude: number;
address?: string;
name?: string;
cityname?: string;
};
export type LbsPlaceItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
return {
latitude: item.latitude,
longitude: item.longitude,
address: item.address || undefined,
name: item.title || undefined,
cityname: item.city || undefined,
};
}
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
@@ -9,14 +14,24 @@ export type UploadFileResult = {
mock: boolean;
};
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export async function uploadFileToOss(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData();
formData.append('file', file);
formData.append('file', prepared);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
+9 -42
View File
@@ -1,45 +1,12 @@
export type UserLogCategory =
| 'login'
| 'browse_product'
| 'order'
| 'pay'
| 'wechat_auth'
| 'browse_store'
| 'redeem'
| 'profile';
export {
USER_LOG_CATEGORY_OPTIONS,
resolveUserLogCategory,
eventNamesForUserLogCategory,
type UserLogCategory,
} from '@dukang/shared-types';
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
browse_product: ['home_view', 'product_click', 'product_detail_view'],
order: ['order_confirm_view', 'order_submit'],
pay: ['pay_success', 'pay_fail'],
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
browse_store: ['store_list_view', 'store_detail_view'],
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
profile: ['profile_update', 'bind_phone'],
};
import { USER_LOG_CATEGORY_OPTIONS } from '@dukang/shared-types';
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'browse_product', label: '浏览商品' },
{ value: 'order', label: '下单' },
{ value: 'pay', label: '支付' },
{ value: 'wechat_auth', label: '微信授权' },
{ value: 'browse_store', label: '浏览门店' },
{ value: 'redeem', label: '核销' },
{ value: 'profile', label: '信息修改' },
];
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
[UserLogCategory, readonly string[]]
>) {
if (events.includes(eventName)) return category;
}
return null;
}
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
export const USER_LOG_CATEGORY_LABELS: Record<string, string> = Object.fromEntries(
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
) as Record<string, string>;
);
+173 -20
View File
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { useAdminList } from '../lib/useAdminList';
type Row = {
@@ -33,6 +34,34 @@ type Row = {
order?: { orderNo: string } | null;
};
type CouponRedeemRecord = {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
couponAmount?: number;
role?: 'PRIMARY' | 'SECONDARY';
createdAt: string;
store?: { id: string; name: string; cityName?: string | null } | null;
};
type CouponRedeemSummary = {
couponNo: string;
totalAmount: number;
usedAmount: number;
balance: number;
status: string;
redeemCount: number;
redeemRecordSum: number;
};
type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[];
user?: { userNo?: string; phone?: string | null };
order?: { orderNo?: string } | null;
};
export default function BenefitCouponsPage() {
const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
@@ -50,8 +79,25 @@ export default function BenefitCouponsPage() {
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [detail, setDetail] = useState<CouponDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
try {
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
setRedeemDetail(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载核销详情失败');
setRedeemDrawerOpen(false);
} finally {
setRedeemDetailLoading(false);
}
}
const columns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
@@ -197,7 +243,8 @@ export default function BenefitCouponsPage() {
<Drawer
title="权益券详情"
width={560}
width={980}
styles={{ body: { paddingBottom: 24 } }}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
@@ -218,26 +265,132 @@ export default function BenefitCouponsPage() {
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{String(detail.couponNo)}</Descriptions.Item>
<Descriptions.Item label="用户">
{String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="手机号">
{String((detail.user as { phone?: string } | undefined)?.phone ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="关联订单">
{String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item>
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item>
<Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}
</Descriptions.Item>
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item>
</Descriptions>
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</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="状态">
{COUPON_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
</Typography.Title>
{detail.redeemSummary ? (
<>
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
<Descriptions.Item label="权益总额">
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="已核销">
<Typography.Text type="danger" strong>
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{detail.redeemSummary.redeemCount}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="剩余余额">
¥{Number(detail.redeemSummary.balance).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销单合计额">
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
</Typography.Text>
</Descriptions.Item>
</Descriptions>
<Table
size="small"
rowKey="id"
pagination={false}
tableLayout="fixed"
locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []}
columns={[
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
{
title: '角色',
dataIndex: 'role',
width: 72,
render: (v: string | undefined) =>
v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>,
},
{
title: '门店',
dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row: CouponRedeemRecord) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
},
{
title: '本券分摊',
dataIndex: 'couponAmount',
width: 96,
render: (v: number | undefined, row: CouponRedeemRecord) =>
`¥${Number(v ?? row.amount).toFixed(2)}`,
},
{
title: '核销总额',
dataIndex: 'amount',
width: 96,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '结算额',
dataIndex: 'settleAmount',
width: 96,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '时间',
dataIndex: 'createdAt',
width: 148,
render: (v: string) => fmtTime(v),
},
{
title: '操作',
width: 64,
render: (_: unknown, row: CouponRedeemRecord) => (
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
</Button>
),
},
]}
/>
</>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</>
)}
</Drawer>
<Drawer
title="核销单详情"
width={640}
open={redeemDrawerOpen}
onClose={() => {
setRedeemDrawerOpen(false);
setRedeemDetail(null);
}}
destroyOnClose
>
{redeemDetailLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : redeemDetail ? (
<RedeemRecordDetailDescriptions detail={redeemDetail} />
) : null}
</Drawer>
</div>
);
}
+525
View File
@@ -0,0 +1,525 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { request } from '../lib/api';
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
export type BigScreenOrder = {
id: string;
orderNo: string;
payAmount: number;
items: string;
/** 展示用时间(付款成功时间) */
createdAt: string;
paidAt?: string;
userPhoneMasked: string | null;
};
type AmountTier = 1 | 2 | 3;
const POLL_MS = 3000;
const ROW_MS = 2500;
/** 不足此条数时重复填充,保证滚动连贯 */
const MIN_SCROLL_ROWS = 10;
const FX_DURATION_MS = 10_000;
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const CELEBRATE_SFX: Record<AmountTier, string> = {
1: sfxTier1,
2: sfxTier2,
3: sfxTier3,
};
/** 浏览器自动播放策略:需用户手势后才能出声 */
let audioUnlocked = false;
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
function unlockCelebrateAudio() {
if (audioUnlocked || !sharedAudio) return;
sharedAudio.muted = true;
sharedAudio.src = CELEBRATE_SFX[1];
void sharedAudio
.play()
.then(() => {
sharedAudio.pause();
sharedAudio.currentTime = 0;
sharedAudio.muted = false;
audioUnlocked = true;
})
.catch(() => {
/* 等待下次手势 */
});
}
function playCelebrateSfx(tier: AmountTier): () => void {
if (!sharedAudio) return () => undefined;
try {
sharedAudio.pause();
sharedAudio.currentTime = 0;
sharedAudio.src = CELEBRATE_SFX[tier];
sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
sharedAudio.muted = false;
void sharedAudio.play().catch(() => {
/* 未解锁时静默失败 */
});
} catch {
/* ignore */
}
return () => {
try {
sharedAudio.pause();
sharedAudio.currentTime = 0;
} catch {
/* ignore */
}
};
}
function pad(n: number) {
return String(n).padStart(2, '0');
}
function formatClock(d: Date) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatDateLine(d: Date) {
return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`;
}
function formatOrderTime(iso: string): string {
try {
return formatClock(new Date(iso));
} catch {
return iso;
}
}
// function formatAmount(n: number): string {
// return Number(n || 0).toLocaleString('zh-CN', {
// minimumFractionDigits: 0,
// maximumFractionDigits: 0,
// });
// }
function formatAmount(n: number): string {
const num = Number(n || 0);
// 先保留两位小数,再分割处理
const fixed = num.toFixed(2);
const [intStr, decStr] = fixed.split('.');
const intFormatted = Number(intStr).toLocaleString('zh-CN');
return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`;
}
function amountCssClass(payAmount: number, base: string): string {
const wide = Math.floor(Math.abs(Number(payAmount || 0))) >= 100_000;
return wide ? `${base} ${base}--6d` : base;
}
export function amountTier(payAmount: number): AmountTier {
if (payAmount >= 1000) return 3;
if (payAmount >= 500) return 2;
return 1;
}
function LiveClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return (
<div className="big-screen-hero">
<div className="big-screen-clock">{formatClock(now)}</div>
<div className="big-screen-date">{formatDateLine(now)}</div>
</div>
);
}
function OrderRow({
order,
latest,
}: {
order: BigScreenOrder;
latest?: boolean;
}) {
const tier = amountTier(order.payAmount);
return (
<div
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
>
{latest ? <span className="big-screen-row-mark" /> : null}
<span className={amountCssClass(order.payAmount, 'big-screen-amount')}>
¥ {formatAmount(order.payAmount)}
</span>
<span className="big-screen-items">{order.items || '—'}</span>
<span className="big-screen-time">{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
</div>
);
}
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
rot: number;
vr: number;
w: number;
h: number;
color: string;
life: number;
kind: 'rect' | 'ribbon' | 'spark';
rain?: boolean;
};
const TIER_COLORS: Record<AmountTier, string[]> = {
1: ['#e6f7ff', '#91d5ff', '#40a9ff', '#ffffff', '#69c0ff'],
2: ['#fff1b8', '#ffe58f', '#ffd666', '#fffbe6', '#ffe7ba'],
3: ['#ffd666', '#faad14', '#ffec3d', '#fff1b8', '#ff4d4f', '#ff7a45', '#ffffff'],
};
function spawnParticles(tier: AmountTier, w: number, h: number): Particle[] {
const colors = TIER_COLORS[tier];
const count = tier === 3 ? 180 : tier === 2 ? 110 : 70;
const out: Particle[] = [];
const cx = w / 2;
const cy = h * 0.42;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = (tier === 3 ? 8 : tier === 2 ? 6 : 4) * (0.4 + Math.random());
const kind: Particle['kind'] =
tier === 3 && Math.random() < 0.25 ? 'ribbon' : Math.random() < 0.2 ? 'spark' : 'rect';
out.push({
x: cx + (Math.random() - 0.5) * 80,
y: cy + (Math.random() - 0.5) * 40,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - (tier === 3 ? 6 : 3),
rot: Math.random() * 360,
vr: (Math.random() - 0.5) * 18,
w: kind === 'ribbon' ? 10 + Math.random() * 16 : kind === 'spark' ? 2 : 6 + Math.random() * 8,
h: kind === 'ribbon' ? 28 + Math.random() * 24 : kind === 'spark' ? 10 + Math.random() * 8 : 4 + Math.random() * 6,
color: colors[Math.floor(Math.random() * colors.length)],
life: 1,
kind,
});
}
if (tier >= 2) {
for (let i = 0; i < (tier === 3 ? 80 : 40); i++) {
out.push({
x: Math.random() * w,
y: -20 - Math.random() * 80,
vx: (Math.random() - 0.5) * 1.4,
vy: 3 + Math.random() * 5,
rot: Math.random() * 360,
vr: (Math.random() - 0.5) * 10,
w: 5 + Math.random() * 8,
h: 10 + Math.random() * 14,
color: colors[Math.floor(Math.random() * colors.length)],
life: 1,
kind: 'rect',
rain: true,
});
}
}
return out;
}
function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => void }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
const tier = amountTier(order.payAmount);
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
useEffect(() => {
const timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS);
return () => window.clearTimeout(timer);
}, [order.id]);
useEffect(() => {
const stop = playCelebrateSfx(tier);
return stop;
}, [order.id, tier]);
useEffect(() => {
if (tier !== 3) return;
const start = performance.now();
const dur = 900;
let raf = 0;
const tick = (now: number) => {
const p = Math.min(1, (now - start) / dur);
const eased = 1 - (1 - p) ** 3;
setDisplayAmount(Math.round(order.payAmount * eased));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [order.payAmount, order.id, tier]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
};
resize();
const particles = spawnParticles(tier, canvas.clientWidth, canvas.clientHeight);
let raf = 0;
let last = performance.now();
const started = performance.now();
const gravity = tier === 3 ? 0.18 : 0.14;
const loop = (now: number) => {
const dt = Math.min(32, now - last) / 16.6;
last = now;
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
for (const p of particles) {
p.vy += gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
p.life -= (tier === 3 ? 0.002 : 0.003) * dt;
if (
p.rain &&
(p.y > canvas.clientHeight + 30 || p.life <= 0) &&
now - started < FX_DURATION_MS - 400
) {
p.x = Math.random() * canvas.clientWidth;
p.y = -16 - Math.random() * 60;
p.vx = (Math.random() - 0.5) * 1.4;
p.vy = 3 + Math.random() * 5;
p.life = 1;
}
if (p.life <= 0) continue;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate((p.rot * Math.PI) / 180);
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
if (p.kind === 'spark') {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
const onResize = () => resize();
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
};
}, [order.id, tier]);
return (
<div className={`big-screen-fx big-screen-fx--t${tier}`} role="presentation">
<canvas ref={canvasRef} className="big-screen-fx-canvas" />
{tier === 3 ? <div className="big-screen-fx-shock" /> : null}
{tier === 3 ? <div className="big-screen-fx-shock big-screen-fx-shock--late" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
<div className={amountCssClass(displayAmount || order.payAmount, 'big-screen-fx-amount')}>
¥ {formatAmount(displayAmount)}
</div>
<div className="big-screen-fx-items">{order.items || '—'}</div>
<div className="big-screen-fx-meta">
<span>{order.userPhoneMasked || '—'}</span>
<span>{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
</div>
</div>
</div>
);
}
export default function BigScreenPage() {
const [items, setItems] = useState<BigScreenOrder[]>([]);
const [loadError, setLoadError] = useState('');
const [paused, setPaused] = useState(false);
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
const seenIdsRef = useRef<Set<string> | null>(null);
const celebratedIdsRef = useRef<Set<string>>(new Set());
const queueRef = useRef<BigScreenOrder[]>([]);
const celebratingRef = useRef(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const dayKeyRef = useRef('');
const [viewportH, setViewportH] = useState(0);
const playNext = useCallback(() => {
const next = queueRef.current.shift() ?? null;
celebratingRef.current = !!next;
setCelebrate(next);
setPaused(!!next);
}, []);
const enqueueNew = useCallback(
(fresh: BigScreenOrder[]) => {
const toCelebrate = fresh.filter((o) => !celebratedIdsRef.current.has(o.id));
if (!toCelebrate.length) return;
for (const o of toCelebrate) celebratedIdsRef.current.add(o.id);
const ranked = [...toCelebrate].sort((a, b) => {
const td = amountTier(b.payAmount) - amountTier(a.payAmount);
if (td !== 0) return td;
return +new Date(b.paidAt ?? b.createdAt) - +new Date(a.paidAt ?? a.createdAt);
});
queueRef.current.push(...ranked);
if (!celebratingRef.current) playNext();
},
[playNext],
);
const fetchData = useCallback(() => {
const now = new Date();
const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
if (dayKeyRef.current && dayKeyRef.current !== todayKey) {
seenIdsRef.current = null;
celebratedIdsRef.current = new Set();
queueRef.current = [];
celebratingRef.current = false;
setCelebrate(null);
setPaused(false);
}
dayKeyRef.current = todayKey;
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
.then((d) => {
const list = d.items ?? [];
setItems(list);
setLoadError('');
const seen = seenIdsRef.current;
if (!seen) {
seenIdsRef.current = new Set(list.map((o) => o.id));
for (const o of list) celebratedIdsRef.current.add(o.id);
return;
}
const fresh = list.filter((o) => !seen.has(o.id));
for (const o of fresh) seen.add(o.id);
enqueueNew(fresh);
})
.catch((e) => {
setLoadError(e instanceof Error ? e.message : '加载失败');
});
}, [enqueueNew]);
useEffect(() => {
void fetchData();
const id = setInterval(() => void fetchData(), POLL_MS);
return () => clearInterval(id);
}, [fetchData]);
useEffect(() => {
unlockCelebrateAudio();
const unlock = () => unlockCelebrateAudio();
window.addEventListener('pointerdown', unlock, { once: true });
window.addEventListener('keydown', unlock, { once: true });
return () => {
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock);
};
}, []);
useEffect(() => {
const html = document.documentElement;
const prevHtml = html.style.overflow;
const prevBody = document.body.style.overflow;
html.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
return () => {
html.style.overflow = prevHtml;
document.body.style.overflow = prevBody;
};
}, []);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => setViewportH(el.clientHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const unitItems = useMemo(() => {
if (!items.length) return [];
const rowH = 80;
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
const minCount = Math.max(MIN_SCROLL_ROWS, visible + 1);
const unit: BigScreenOrder[] = [];
while (unit.length < minCount) unit.push(...items);
return unit;
}, [items, viewportH]);
const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]);
const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS;
const rolling = items.length > 0 && unitItems.length > 0;
const latestId = items[0]?.id;
return (
<div className="big-screen-page">
<div className="big-screen-stars" aria-hidden />
<div className="big-screen-frame" aria-hidden>
<span className="big-screen-corner big-screen-corner--tl" />
<span className="big-screen-corner big-screen-corner--tr" />
<span className="big-screen-corner big-screen-corner--bl" />
<span className="big-screen-corner big-screen-corner--br" />
</div>
<header className="big-screen-header">
<div className="big-screen-brand">
<h1 className="big-screen-title"></h1>
<span className="big-screen-subtitle">· </span>
</div>
<div className="big-screen-live-wrap">
<span className="big-screen-live-label"></span>
<span className="big-screen-live">
<span className="big-screen-live-dot" />
LIVE
</span>
</div>
</header>
<LiveClock />
<div className="big-screen-list">
<div className="big-screen-list-body" ref={viewportRef}>
{items.length === 0 ? null : (
<div
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
>
{trackItems.map((o, idx) => (
<OrderRow
key={`${o.id}-${idx}`}
order={o}
latest={idx % items.length === 0 && o.id === latestId}
/>
))}
</div>
)}
</div>
</div>
{items.length === 0 ? (
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
) : null}
{celebrate ? (
<CelebrateFx
key={celebrate.id}
order={celebrate}
onDone={playNext}
/>
) : null}
</div>
);
}
+271 -6
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button,
Descriptions,
@@ -17,7 +18,7 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { request, type HqProfile, type Paginated } from '../lib/api';
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
@@ -50,6 +51,40 @@ type Row = {
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
type CityDeletePreview = {
city: { id: string; code: string; name: string; province: string; status: string };
canDelete: boolean;
blockers: string[];
warnings: string[];
summary: {
primaryPartnerCount: number;
staffCount: number;
storeCount: number;
warehouseCount: number;
orderCount: number;
redeemCount: number;
partnerBillCount: number;
};
partners: Array<{
id: string;
phone: string;
name: string;
companyName?: string | null;
status: string;
staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
}>;
orphanStaff: Array<{ id: string; phone: string; name: string }>;
stores: Array<{
id: string;
name: string;
phone: string;
status: string;
address: string;
partnerAccount?: { companyName?: string | null; phone?: string } | null;
}>;
warehouses: Array<{ id: string; name: string; status: string; address: string }>;
};
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() {
@@ -82,6 +117,18 @@ export default function CitiesPage() {
const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
const [confirmName, setConfirmName] = useState('');
const [profile, setProfile] = useState<HqProfile | null>(null);
const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
@@ -129,22 +176,85 @@ export default function CitiesPage() {
setDrawerOpen(true);
};
const openDelete = async (row: Row) => {
setDeleteTarget(row);
setDeletePreview(null);
setConfirmName('');
setDeleteOpen(true);
setDeleteLoading(true);
try {
const preview = await request<CityDeletePreview>(`/admin/cities/${row.id}/delete-preview`);
setDeletePreview(preview);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载删除预览失败');
setDeleteOpen(false);
} finally {
setDeleteLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget || !deletePreview) return;
if (!deletePreview.canDelete) {
message.error(deletePreview.blockers.join('') || '当前城市不可删除');
return;
}
if (confirmName.trim() !== deletePreview.city.name) {
message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
return;
}
setDeleteSubmitting(true);
try {
await request(`/admin/cities/${deleteTarget.id}`, {
method: 'DELETE',
body: JSON.stringify({ confirmName: confirmName.trim() }),
});
message.success(`已删除城市「${deletePreview.city.name}`);
setDeleteOpen(false);
setDeleteTarget(null);
setDeletePreview(null);
setConfirmName('');
if (detail?.id === deleteTarget.id) setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleteSubmitting(false);
}
};
const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
{ title: '门店', dataIndex: 'storeCount', width: 70 },
{
title: '门店',
dataIndex: 'storeCount',
width: 70,
render: (n: number, row) => (
<Link to={`/stores?cityId=${row.id}`} title={`查看「${row.name}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
width: 140,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
{canDeleteCities ? (
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
</Button>
) : null}
</Space>
),
},
];
@@ -264,6 +374,26 @@ export default function CitiesPage() {
setDetail(refreshed);
void reload();
}}></Button>
{canDeleteCities ? (
<Button
danger
style={{ marginLeft: 8 }}
onClick={() =>
void openDelete({
id: String(detail.id),
code: String(detail.code),
name: String(detail.name),
province: String(detail.province),
status: String(detail.status),
storeCount: Number(detail.storeCount ?? 0),
orderCount: Number(detail.orderCount ?? 0),
createdAt: String(detail.createdAt ?? ''),
})
}
>
</Button>
) : null}
</Form>
</>
),
@@ -274,6 +404,7 @@ export default function CitiesPage() {
children: detail?.id ? (
<CityPartnersPanel
cityId={String(detail.id)}
cityCode={detail.code != null ? String(detail.code) : undefined}
maxPartnerCommissionRate={
detail.maxPartnerCommissionRate != null
? Number(detail.maxPartnerCommissionRate)
@@ -406,6 +537,140 @@ export default function CitiesPage() {
</Form.Item>
</Form>
</Modal>
<Modal
title={deleteTarget ? `删除城市「${deleteTarget.name}` : '删除城市'}
open={deleteOpen}
onCancel={() => {
if (deleteSubmitting) return;
setDeleteOpen(false);
}}
okText="确认删除"
okButtonProps={{
danger: true,
disabled:
!deletePreview?.canDelete ||
!deletePreview ||
confirmName.trim() !== (deletePreview?.city.name ?? ''),
loading: deleteSubmitting,
}}
confirmLoading={deleteSubmitting}
onOk={() => void confirmDelete()}
width={720}
destroyOnClose
>
{deleteLoading || !deletePreview ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" bordered column={2}>
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
</Descriptions>
{deletePreview.blockers.length > 0 && (
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
{deletePreview.blockers.map((b) => (
<div key={b}> {b}</div>
))}
</Typography.Paragraph>
)}
{deletePreview.warnings.length > 0 && (
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
{deletePreview.warnings.map((w) => (
<div key={w}> {w}</div>
))}
</Typography.Paragraph>
)}
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无合伙人' }}
dataSource={deletePreview.partners.flatMap((p) => [
{
id: p.id,
kind: '主账号',
name: p.companyName || p.name,
phone: p.phone,
parent: '—',
},
...p.staff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: p.companyName || p.name,
})),
]).concat(
deletePreview.orphanStaff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: '(无主账号)',
})),
)}
columns={[
{ title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true },
]}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores}
columns={[
{ title: '门店名', dataIndex: 'name', ellipsis: true },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '合伙人',
width: 140,
ellipsis: true,
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
},
]}
/>
</div>
{deletePreview.canDelete ? (
<Form.Item
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
style={{ marginBottom: 0 }}
>
<Input
value={confirmName}
placeholder={deletePreview.city.name}
onChange={(e) => setConfirmName(e.target.value)}
disabled={deleteSubmitting}
/>
</Form.Item>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
)}
</Modal>
</div>
);
}
+155 -78
View File
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
import {
Alert,
Button,
Cascader,
Checkbox,
Descriptions,
Drawer,
@@ -32,9 +31,10 @@ import {
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
type SubRow = PartnerSubAccountRow;
@@ -48,6 +48,7 @@ type Row = {
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
bindingStatus?: string;
@@ -58,6 +59,7 @@ type Row = {
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
isTest?: boolean;
};
type PartnerDetail = Row & {
@@ -98,27 +100,40 @@ function flattenDistrictCodes(values: string[] | string[][] | undefined): string
}
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
const sum = orderPercent / 100 + redeemPercent / 100;
if (sum > maxRate + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPage() {
const [filterForm] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.companyName) qs.set('companyName', String(filters.companyName));
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -135,6 +150,7 @@ export default function CityPartnersPage() {
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05);
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -145,11 +161,15 @@ export default function CityPartnersPage() {
void loadCities();
}, [loadCities]);
const editCityCode = detail?.cityId
? cities.find((c) => c.id === detail.cityId)?.code
: undefined;
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
@@ -171,28 +191,33 @@ export default function CityPartnersPage() {
async function savePartner() {
if (!detail) return;
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxCommissionRate,
);
if (err) {
message.error(err);
return;
try {
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void reload();
}
async function refreshPartnerContext(parentId: string) {
@@ -224,19 +249,41 @@ export default function CityPartnersPage() {
async function openAddSubAccount(parentId: string) {
await openPartner(parentId);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
async function onCreateCityChange(cityId: string) {
createForm.setFieldsValue({ districtCodes: undefined });
const matched = cities.find((c) => c.id === cityId);
setCreateCityCode(matched?.code);
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
}
const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
{
title: '主账号姓名',
dataIndex: 'name',
width: 120,
ellipsis: true,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
title: '管辖',
@@ -267,7 +314,16 @@ export default function CityPartnersPage() {
ellipsis: true,
render: (v) => v || '—',
},
{ title: '门店', dataIndex: 'storeCount', width: 60 },
{
title: '门店',
dataIndex: 'storeCount',
width: 60,
render: (n: number, row) => (
<Link to={`/stores?partnerId=${row.id}`} title={`查看「${row.companyName || row.phone}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
@@ -299,6 +355,7 @@ export default function CityPartnersPage() {
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05);
setCreateCityCode(undefined);
setCreateOpen(true);
}}
>
@@ -327,12 +384,6 @@ export default function CityPartnersPage() {
setPage(1);
}}
>
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
@@ -343,6 +394,15 @@ export default function CityPartnersPage() {
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
@@ -409,7 +469,11 @@ export default function CityPartnersPage() {
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="门店数">
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
{detail.storeCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? (
<Typography.Text type="secondary">
@@ -425,8 +489,8 @@ export default function CityPartnersPage() {
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
<Input />
<Form.Item name="companyName" label="公司名">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
@@ -437,8 +501,8 @@ export default function CityPartnersPage() {
<Form.Item name="contactPhone" label="业务联系电话">
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
<Form.Item name="address" label="地址">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
@@ -447,8 +511,12 @@ export default function CityPartnersPage() {
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县">
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
@@ -482,7 +550,7 @@ export default function CityPartnersPage() {
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(sub) => openSubEdit(sub, detail.id)}
@@ -502,29 +570,34 @@ export default function CityPartnersPage() {
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
createMaxRate,
);
if (err) {
message.error(err);
return;
try {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
createMaxRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes:
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes:
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void reload();
}}
>
<Form form={createForm} layout="vertical">
@@ -536,8 +609,8 @@ export default function CityPartnersPage() {
onChange={(id) => void onCreateCityChange(id)}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
<Input />
<Form.Item name="companyName" label="公司名">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
@@ -545,15 +618,19 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
<Form.Item name="address" label="地址">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
+151 -42
View File
@@ -6,10 +6,12 @@ import {
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
@@ -17,10 +19,13 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WAREHOUSE_FULFILLMENT_MODE_LABELS,
WAREHOUSE_MANAGER_LABELS,
WAREHOUSE_STATUS_LABELS,
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
@@ -39,6 +44,13 @@ type Row = {
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
fulfillmentMode?: string;
fulfillmentProviderId?: string | null;
fulfillmentProviderName?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
createdAt: string;
};
@@ -48,6 +60,79 @@ type PartnerOption = { id: string; companyName: string };
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
function FulfillmentFields({
mode,
providerOptions,
onModeChange,
}: {
mode: WarehouseFulfillmentMode;
providerOptions: FulfillmentProviderDto[];
onModeChange: (mode: WarehouseFulfillmentMode) => void;
}) {
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
return (
<>
<Form.Item
name="fulfillmentMode"
label="支付后自动发货"
extra={
autoShip
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
}
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
getValueFromEvent={(checked: boolean) =>
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
}
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
onModeChange(
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
);
}}
/>
</Form.Item>
{autoShip && (
<Form.Item
name="fulfillmentProviderId"
label="仓配承运商"
rules={[{ required: true, message: '自动发货须选择承运商' }]}
>
<Select
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
/>
</Form.Item>
)}
{!autoShip && (
<>
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
<Input placeholder="如 顺丰速运" />
</Form.Item>
<Form.Item
name="manualQueryUrlTemplate"
label="物流查询链接模板"
extra="可用 {trackingNo} 占位符"
>
<Input placeholder="https://example.com/track?no={trackingNo}" />
</Form.Item>
</>
)}
<Space>
<Form.Item name="lng" label="经度">
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
</Form.Item>
<Form.Item name="lat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
);
}
export default function CityWarehousesPage() {
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
@@ -73,6 +158,13 @@ export default function CityWarehousesPage() {
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -88,6 +180,9 @@ export default function CityWarehousesPage() {
useEffect(() => {
void loadCities();
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
.then(setProviderOptions)
.catch(() => {});
}, [loadCities]);
async function openEdit(row: Row) {
@@ -102,23 +197,29 @@ export default function CityWarehousesPage() {
managerType: row.managerType,
partnerAccountId: row.partnerAccountId,
status: row.status,
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
lng: row.lng ?? undefined,
lat: row.lat ?? undefined,
});
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
setEditOpen(true);
}
function onManagerTypeChange(
type: WarehouseManagerType,
form: typeof createForm | typeof editForm,
setType: (v: WarehouseManagerType) => void,
v: WarehouseManagerType,
form: typeof createForm,
setType: (t: WarehouseManagerType) => void,
) {
setType(type);
if (type !== WarehouseManagerType.PARTNER) {
setType(v);
if (v !== WarehouseManagerType.PARTNER) {
form.setFieldValue('partnerAccountId', undefined);
}
}
const columns: ColumnsType<Row> = [
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
{
title: '城市',
width: 100,
@@ -128,17 +229,17 @@ export default function CityWarehousesPage() {
</span>
),
},
{ title: '地址', dataIndex: 'address', ellipsis: true },
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
title: '管仓类型',
dataIndex: 'managerType',
width: 100,
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
title: '管仓',
width: 90,
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
},
{
title: '管仓合伙人',
title: '合伙人',
dataIndex: 'partnerCompanyName',
width: 120,
ellipsis: true,
@@ -149,6 +250,16 @@ export default function CityWarehousesPage() {
'—'
),
},
{
title: '自动发货',
width: 130,
render: (_, row) =>
row.fulfillmentMode === 'API_AUTO' ? (
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
) : (
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -190,21 +301,23 @@ export default function CityWarehousesPage() {
return (
<div>
<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>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
setCreateManagerType(WarehouseManagerType.HQ);
setCreateCityId(undefined);
setPartners([]);
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
createForm.setFieldsValue({
managerType: WarehouseManagerType.HQ,
status: WarehouseStatus.ACTIVE,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
});
setCreateOpen(true);
}}
>
</Button>
</Space>
@@ -212,7 +325,7 @@ export default function CityWarehousesPage() {
type="info"
showIcon
style={{ marginBottom: 16 }}
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
/>
<Form
@@ -225,7 +338,7 @@ export default function CityWarehousesPage() {
}}
>
<Form.Item name="name" label="仓库名">
<Input allowClear placeholder="仓库名" />
<Input allowClear />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
@@ -233,47 +346,33 @@ export default function CityWarehousesPage() {
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="managerType" label="管仓类型">
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
<Form.Item name="managerType" label="管仓">
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} />
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
@@ -282,7 +381,7 @@ export default function CityWarehousesPage() {
/>
<Modal
title="新仓库"
title="新仓库"
open={createOpen}
width={520}
onCancel={() => setCreateOpen(false)}
@@ -341,6 +440,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={createFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setCreateFulfillmentMode}
/>
</Form>
</Modal>
@@ -399,6 +503,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态">
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={editFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setEditFulfillmentMode}
/>
</Form>
</Modal>
</div>
+595 -15
View File
@@ -1,16 +1,22 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Select, Space, Statistic, Table, Typography, message,
} from 'antd';
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
import {
request,
type DashboardAnalytics,
type DashboardStats,
type DeployTriggerResult,
type HqProfile,
type Paginated,
type SystemVersion,
} from '../lib/api';
import { fmtTime } from '../lib/constants';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
@@ -30,6 +36,26 @@ const DEPLOYED_BY_LABELS: Record<string, string> = {
admin: 'Admin 发布',
};
type CityOption = { id: string; name: string; code: string };
type PromoOption = { id: string; code: string; name: string };
type AnalyticsFilters = {
range: [Dayjs, Dayjs];
cityId?: string;
promoCodeId?: string;
partnerAccountId?: string;
};
function buildAnalyticsQs(f: AnalyticsFilters) {
const qs = new URLSearchParams();
qs.set('dateFrom', f.range[0].format('YYYY-MM-DD'));
qs.set('dateTo', f.range[1].format('YYYY-MM-DD'));
if (f.cityId) qs.set('cityId', f.cityId);
if (f.promoCodeId) qs.set('promoCodeId', f.promoCodeId);
if (f.partnerAccountId) qs.set('partnerAccountId', f.partnerAccountId);
return qs.toString();
}
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [version, setVersion] = useState<SystemVersion | null>(null);
@@ -39,6 +65,21 @@ export default function DashboardPage() {
const [deploying, setDeploying] = useState(false);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
const [filterForm] = Form.useForm<{
range: [Dayjs, Dayjs];
cityId?: string;
promoCodeId?: string;
partnerAccountId?: string;
}>();
const [filters, setFilters] = useState<AnalyticsFilters>({
range: [dayjs().subtract(29, 'day'), dayjs()],
});
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(true);
const [cities, setCities] = useState<CityOption[]>([]);
const [promos, setPromos] = useState<PromoOption[]>([]);
const [partners, setPartners] = useState<Array<{ id: string; companyName?: string | null; name: string }>>([]);
const loadVersion = useCallback(() => {
setVersionLoading(true);
return request<SystemVersion | null>('/admin/dashboard/version')
@@ -47,14 +88,91 @@ export default function DashboardPage() {
.finally(() => setVersionLoading(false));
}, []);
const loadAnalytics = useCallback((f: AnalyticsFilters) => {
setAnalyticsLoading(true);
return request<DashboardAnalytics>(`/admin/dashboard/analytics?${buildAnalyticsQs(f)}`)
.then(setAnalytics)
.catch((e) => {
message.error(e instanceof Error ? e.message : '加载统计失败');
setAnalytics(null);
})
.finally(() => setAnalyticsLoading(false));
}, []);
useEffect(() => {
request<DashboardStats>('/admin/dashboard/stats')
.then(setStats)
.finally(() => setLoading(false));
void loadVersion();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
request<HqProfile>('/admin/auth/me')
.then((p) => {
setProfile(p);
if (p.adminRole === 'SUPER_ADMIN') {
void loadVersion();
} else {
setVersionLoading(false);
}
})
.catch(() => {
setVersionLoading(false);
});
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => setCities([]));
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPromos(res.items ?? []))
.catch(() => setPromos([]));
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, [loadVersion]);
useEffect(() => {
void loadAnalytics(filters);
}, [filters, loadAnalytics]);
function applyFilters(values: {
range?: [Dayjs, Dayjs];
cityId?: string;
promoCodeId?: string;
partnerAccountId?: string;
}) {
const next: AnalyticsFilters = {
range: values.range ?? filters.range,
cityId: values.cityId || undefined,
promoCodeId: values.promoCodeId || undefined,
partnerAccountId: values.partnerAccountId || undefined,
};
setFilters(next);
}
function setCityFilter(cityId: string) {
const next = { ...filters, cityId: cityId === filters.cityId ? undefined : cityId };
filterForm.setFieldsValue({ cityId: next.cityId });
setFilters(next);
}
function setPromoFilter(promoCodeId: string) {
const key = promoCodeId === 'null' || promoCodeId === '' ? 'none' : promoCodeId;
const next = {
...filters,
promoCodeId: key === filters.promoCodeId ? undefined : key,
};
filterForm.setFieldsValue({ promoCodeId: next.promoCodeId });
setFilters(next);
}
function setPartnerFilter(partnerAccountId: string) {
const next = {
...filters,
partnerAccountId:
partnerAccountId === filters.partnerAccountId ? undefined : partnerAccountId,
};
filterForm.setFieldsValue({ partnerAccountId: next.partnerAccountId });
setFilters(next);
}
function handleDeploy() {
Modal.confirm({
title: '确认发布更新?',
@@ -78,10 +196,181 @@ export default function DashboardPage() {
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
const ordersDrillQs = useMemo(() => {
const qs = new URLSearchParams();
qs.set('createdFrom', filters.range[0].format('YYYY-MM-DD'));
qs.set('createdTo', filters.range[1].format('YYYY-MM-DD'));
if (filters.cityId && filters.cityId !== 'none') qs.set('cityId', filters.cityId);
return qs.toString();
}, [filters]);
const byDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['新增用户', '订单数'] },
grid: { left: 40, right: 20, top: 40, bottom: 40 },
xAxis: {
type: 'category',
data: rows.map((r) => r.date.slice(5)),
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
],
};
}, [analytics]);
const byCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] },
grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: {
type: 'category',
data: rows.map((r) => r.cityName),
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
};
}, [analytics]);
const byPromoOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPromo ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] },
grid: { left: 48, right: 20, top: 40, bottom: 64 },
xAxis: {
type: 'category',
data: rows.map((r) => r.name || r.code),
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
};
}, [analytics]);
const opsByDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] },
grid: { left: 48, right: 48, top: 48, bottom: 40 },
xAxis: {
type: 'category',
data: rows.map((r) => r.date.slice(5)),
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
},
yAxis: [
{ type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额', minInterval: 1 },
],
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]);
const opsByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['合伙人', '门店', '核销笔数'] },
grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: {
type: 'category',
data: rows.map((r) => r.cityName),
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
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]);
const opsByPartnerOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPartner ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['门店', '核销笔数', '核销金额'] },
grid: { left: 48, right: 48, top: 40, bottom: 64 },
xAxis: {
type: 'category',
data: rows.map((r) => r.companyName),
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
},
yAxis: [
{ type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额' },
],
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]);
const redeemByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['核销笔数', '核销金额'] },
grid: { left: 48, right: 48, top: 40, bottom: 48 },
xAxis: {
type: 'category',
data: rows.map((r) => r.cityName),
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
},
yAxis: [
{ type: 'value', name: '笔数', minInterval: 1 },
{ type: 'value', name: '金额' },
],
series: [
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 36 },
{
name: '核销金额',
type: 'line',
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
},
],
};
}, [analytics]);
return (
<div>
<Typography.Title level={4}></Typography.Title>
{isSuperAdmin ? (
<Card
title="系统版本"
loading={versionLoading}
@@ -91,16 +380,14 @@ export default function DashboardPage() {
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
</Button>
{isSuperAdmin && (
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={deploying}
onClick={handleDeploy}
>
</Button>
)}
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={deploying}
onClick={handleDeploy}
>
</Button>
</Space>
}
>
@@ -121,6 +408,7 @@ export default function DashboardPage() {
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
@@ -164,6 +452,298 @@ export default function DashboardPage() {
</Card>
</Col>
</Row>
<Card
title="数据统计筛选"
style={{ marginBottom: 24 }}
extra={
<Button
icon={<ReloadOutlined />}
size="small"
onClick={() => void loadAnalytics(filters)}
>
</Button>
}
>
<Form
form={filterForm}
layout="inline"
initialValues={{ range: filters.range }}
onFinish={applyFilters}
>
<Form.Item name="range" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker.RangePicker allowClear={false} />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
placeholder="全部开城"
style={{ width: 160 }}
options={[
{ value: 'none', label: '未选城' },
...cities.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</Form.Item>
<Form.Item name="promoCodeId" label="推广码">
<Select
allowClear
placeholder="全部来源"
style={{ width: 200 }}
options={[
{ value: 'none', label: '自然量 / 无推广码' },
...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })),
]}
/>
</Form.Item>
<Form.Item name="partnerAccountId" label="合伙人">
<Select
allowClear
placeholder="全部合伙人"
style={{ width: 200 }}
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.name,
}))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form.Item>
</Form>
</Card>
<Card
title="用户 / 订单统计"
style={{ marginBottom: 24 }}
extra={
<Space>
<Link to={`/orders?${ordersDrillQs}`}></Link>
<Link to="/users"></Link>
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
</Col>
<Col xs={24} sm={8}>
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
</Col>
<Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
type="inner"
title="按城市(点击柱联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={byCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
type="inner"
title="按推广码(点击柱联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={byPromoOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byPromo ?? []).find(
(r) => (r.name || r.code) === params.name,
);
if (row) setPromoFilter(row.promoCodeId ?? 'none');
},
}}
/>
</Card>
</Col>
</Row>
</Card>
<Card
title="合伙人 / 门店 / 核销统计"
style={{ marginBottom: 24 }}
extra={
<Space>
<Link to="/city-partners"></Link>
<Link to="/stores"></Link>
<Link to="/redeem-records"></Link>
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={6}>
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card type="inner" title="按日趋势(合伙人 / 门店 / 核销)" loading={analyticsLoading} size="small">
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
type="inner"
title="按城市(点击柱联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={opsByCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
type="inner"
title="按城市核销金额"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={redeemByCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
</Card>
</Col>
<Col xs={24}>
<Card
type="inner"
title="按合伙人(门店 / 核销,点击联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={opsByPartnerOption}
style={{ height: 320 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byPartner ?? []).find(
(r) => r.companyName === params.name,
);
if (row) setPartnerFilter(row.partnerAccountId);
},
}}
/>
</Card>
</Col>
</Row>
</Card>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待审核合伙人打款"
extra={<Link to="/finance/partner-bills"></Link>}
>
<Statistic
value={stats?.pendingBills ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
<Typography.Text type="secondary">
{(stats?.pendingPartnerDraftBills ?? 0) > 0
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
: ''}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待打款门店结算"
extra={<Link to="/finance/store-bills"></Link>}
>
<Statistic
value={stats?.pendingPayouts ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待审门店提现"
extra={<Link to="/finance/store-bills?kind=WITHDRAW"></Link>}
>
<Statistic
value={stats?.pendingStoreWithdrawals ?? 0}
suffix="笔"
valueStyle={{
color: (stats?.overdueStoreWithdrawals ?? 0) > 0 ? '#cf1322' : (stats?.pendingStoreWithdrawals ?? 0) > 0 ? '#fa8c16' : undefined,
}}
/>
<Typography.Text type="secondary">
{(stats?.overdueStoreWithdrawals ?? 0) > 0
? `超时未审 ${stats?.overdueStoreWithdrawals} 笔(FIN-003`
: '工作日 T+0 审完'}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title="已合并访客账号" loading={loading}>
+54 -9
View File
@@ -3,13 +3,26 @@ import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
id: string;
orderId: string;
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
updatedAt: string;
order?: {
id: string;
orderNo: string;
status: string;
receiverName: string;
receiverPhone: string;
deliveryType: string;
};
};
export default function DeliveriesPage() {
@@ -29,6 +42,22 @@ export default function DeliveriesPage() {
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
function openTrack(row: Row) {
const orderId = row.orderId || row.order?.id;
if (!orderId) {
message.warning('缺少关联订单,无法查询路由');
return;
}
setTrackOrderId(orderId);
setTrackOrderNo(row.order?.orderNo ?? null);
setTrackOrderStatus(row.order?.status ?? null);
setTrackOpen(true);
}
const columns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
@@ -39,14 +68,17 @@ export default function DeliveriesPage() {
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作', width: 140,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}></Button>
<Space size={0}>
<Button type="link" size="small" onClick={() => openTrack(row)}></Button>
<Button type="link" size="small" onClick={async () => {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}></Button>
</Space>
),
},
];
@@ -87,9 +119,22 @@ export default function DeliveriesPage() {
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form>
<Button
style={{ marginTop: 8 }}
onClick={() => openTrack(detail)}
>
</Button>
</>
)}
</Drawer>
<OrderTrackDrawer
open={trackOpen}
orderId={trackOrderId}
orderNo={trackOrderNo}
orderStatus={trackOrderStatus}
onClose={() => setTrackOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import { Button, Collapse, Form, Input, Select, Typography, message } from 'antd';
import type {
DevPlanSettingsDto,
KnowledgeBaseOptionDto,
LlmApiConfigOptionDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
export default function DevPlanSettingsPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
const [settingsForm] = Form.useForm();
async function loadAll() {
setLoading(true);
try {
const [s, llm, kb] = await Promise.all([
request<DevPlanSettingsDto>('/admin/dev-plan/settings'),
request<LlmApiConfigOptionDto[]>('/admin/llm-configs/options'),
request<KnowledgeBaseOptionDto[]>('/admin/knowledge-bases/options'),
]);
setLlmOptions(llm);
setKbOptions(kb);
settingsForm.setFieldsValue({
reviewAssistantLlmConfigId: s.reviewAssistantLlmConfigId,
reviewAssistantKnowledgeBaseId: s.reviewAssistantKnowledgeBaseId,
reviewAssistantPrompt: s.reviewAssistantPrompt,
});
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadAll();
}, []);
async function saveSettings() {
const values = await settingsForm.validateFields();
setSaving(true);
try {
await request<DevPlanSettingsDto>('/admin/dev-plan/settings', {
method: 'PUT',
body: JSON.stringify(values),
});
message.success('设置已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Typography.Paragraph type="secondary">
Webhook
</Typography.Paragraph>
<Collapse
defaultActiveKey={['review-ai']}
style={{ marginTop: 16 }}
items={[
{
key: 'review-ai',
label: '本地审核 AI 助手',
children: (
<Form form={settingsForm} layout="vertical" disabled={loading}>
<Typography.Paragraph type="secondary">
+ LLMpreview
</Typography.Paragraph>
<Form.Item name="reviewAssistantLlmConfigId" label="语言模型">
<Select
allowClear
options={llmOptions.map((o) => ({ value: o.id, label: o.name }))}
/>
</Form.Item>
<Form.Item name="reviewAssistantKnowledgeBaseId" label="知识库">
<Select
allowClear
options={kbOptions.map((o) => ({ value: o.id, label: o.name }))}
/>
</Form.Item>
<Form.Item name="reviewAssistantPrompt" label="审核提示词">
<Input.TextArea rows={5} />
</Form.Item>
<Button type="primary" loading={saving} onClick={() => void saveSettings()}>
</Button>
</Form>
),
},
]}
/>
</div>
);
}
@@ -0,0 +1,382 @@
import { useEffect, useState } from 'react';
import {
Button,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
DEV_PLAN_TASK_STATUS_LABELS,
DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto,
type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto,
type DevPlanVersionDto,
type SupportTicketDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
value: v,
label: DEV_PLAN_TASK_TYPE_LABELS[v],
}));
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_TASK_STATUS_LABELS) as DevPlanTaskStatusDto[]).map(
(v) => ({ value: v, label: DEV_PLAN_TASK_STATUS_LABELS[v] }),
);
const STATUS_COLOR: Record<DevPlanTaskStatusDto, string> = {
TODO: 'default',
DEVELOPED: 'blue',
RELEASED: 'green',
};
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
export default function DevPlanTasksPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [dispatchOpen, setDispatchOpen] = useState(false);
const [batchEditOpen, setBatchEditOpen] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false);
const [form] = Form.useForm();
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
useEffect(() => {
if (!batchEditOpen) return;
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
.then((res) => setVersions(res.items ?? []))
.catch(() => setVersions([]));
}, [batchEditOpen]);
useEffect(() => {
if (!modalOpen || editing) return;
request<{ items: SupportTicketDto[] }>('/admin/support-tickets?pageSize=100')
.then((res) => setSupportTickets(res.items ?? []))
.catch(() => setSupportTickets([]));
}, [modalOpen, editing]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
'/admin/dev-plan/tasks',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.type) qs.set('type', filters.type);
if (filters.keyword) qs.set('keyword', filters.keyword);
return qs;
},
[filters],
);
function openCreate() {
setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
setModalOpen(true);
}
function openEdit(row: DevPlanTaskDto) {
setEditing(row);
form.setFieldsValue({ content: row.content, type: row.type, status: row.status });
setModalOpen(true);
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(values),
});
message.success('已更新');
} else {
await request('/admin/dev-plan/tasks', {
method: 'POST',
body: JSON.stringify({
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
}),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function remove(id: string) {
try {
await request(`/admin/dev-plan/tasks/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
function openDispatch() {
dispatchForm.setFieldsValue({
supplement: DEFAULT_DISPATCH_SUPPLEMENT,
});
setDispatchOpen(true);
}
function openBatchEdit() {
batchForm.setFieldsValue({ status: undefined, versionId: undefined });
setBatchEditOpen(true);
}
async function submitBatchEdit() {
const values = await batchForm.validateFields();
if (!values.status && !values.versionId) {
message.warning('请至少选择状态或关联版本');
return;
}
setBatchSaving(true);
try {
await request('/admin/dev-plan/tasks/batch-update', {
method: 'POST',
body: JSON.stringify({
taskIds: selectedRowKeys,
status: values.status || undefined,
versionId: values.versionId || undefined,
}),
});
message.success('批量更新成功');
setBatchEditOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量更新失败');
} finally {
setBatchSaving(false);
}
}
async function submitDispatch() {
const values = await dispatchForm.validateFields();
setDispatching(true);
try {
await request('/admin/dev-plan/tasks/dispatch', {
method: 'POST',
body: JSON.stringify({
taskIds: selectedRowKeys,
supplement: values.supplement?.trim() || undefined,
}),
});
message.success('已派发到企微群');
setDispatchOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '派发失败');
} finally {
setDispatching(false);
}
}
const columns: ColumnsType<DevPlanTaskDto> = [
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
{
title: '类型',
dataIndex: 'type',
width: 80,
render: (t: DevPlanTaskTypeDto) => DEV_PLAN_TASK_TYPE_LABELS[t],
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: DevPlanTaskStatusDto) => (
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
),
},
{ title: '内容', dataIndex: 'content', ellipsis: true },
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 120,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
·
</Typography.Title>
<Space>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
</Button>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
</div>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="type" label="类型">
<Select allowClear style={{ width: 100 }} options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="keyword" label="关键词">
<Input allowClear placeholder="任务号/内容" style={{ width: 160 }} />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑任务' : '新建任务'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void save()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="content" label="任务内容" rules={[{ required: true }]}>
<Input.TextArea rows={4} maxLength={4000} showCount />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
{editing ? (
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
) : (
<Form.Item name="supportTicketId" label="绑定技术支持工单">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="可选:关联工单"
options={supportTickets.map((t) => ({
value: t.id,
label: `${t.ticketNo} · ${SUPPORT_TICKET_STATUS_LABELS[t.status]} · ${t.title}`,
}))}
/>
</Form.Item>
)}
</Form>
</Modal>
<Modal
title="评审派发"
open={dispatchOpen}
onCancel={() => setDispatchOpen(false)}
onOk={() => void submitDispatch()}
confirmLoading={dispatching}
destroyOnClose
>
<Typography.Paragraph type="secondary">
{selectedRowKeys.length} @
userid
</Typography.Paragraph>
<Form form={dispatchForm} layout="vertical">
<Form.Item name="supplement" label="补充说明">
<Input.TextArea rows={3} placeholder="默认任务说明;@ 成员由消息推送配置决定" />
</Form.Item>
</Form>
</Modal>
<Modal
title="批量编辑任务"
open={batchEditOpen}
onCancel={() => setBatchEditOpen(false)}
onOk={() => void submitBatchEdit()}
confirmLoading={batchSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary"> {selectedRowKeys.length} </Typography.Paragraph>
<Form form={batchForm} layout="vertical">
<Form.Item name="status" label="状态(不修改请留空)">
<Select allowClear placeholder="不修改" options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="versionId" label="关联版本(追加关联,不修改请留空)">
<Select
allowClear
placeholder="不修改"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react';
import {
Button,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
DEV_PLAN_VERSION_STATUS_LABELS,
type DevPlanTaskDto,
type DevPlanVersionDto,
type DevPlanVersionStatusDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
(v) => ({ value: v, label: DEV_PLAN_VERSION_STATUS_LABELS[v] }),
);
const STATUS_COLOR: Record<DevPlanVersionStatusDto, string> = {
PENDING: 'default',
IN_PROGRESS: 'processing',
TESTING: 'purple',
RELEASED: 'green',
};
function formatDuration(minutes?: number | null) {
if (minutes == null) return '—';
if (minutes < 60) return `${minutes} 分钟`;
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60}`;
}
export default function DevPlanVersionsPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<DevPlanVersionDto | null>(null);
const [saving, setSaving] = useState(false);
const [allTasks, setAllTasks] = useState<DevPlanTaskDto[]>([]);
const [form] = Form.useForm();
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<DevPlanVersionDto>('/admin/dev-plan/versions', () => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
return qs;
}, [filters]);
useEffect(() => {
void request<{ items: DevPlanTaskDto[] }>('/admin/dev-plan/tasks?page=1&pageSize=500')
.then((res) => setAllTasks(res.items ?? []))
.catch(() => setAllTasks([]));
}, []);
async function openEdit(row: DevPlanVersionDto) {
const detail = await request<DevPlanVersionDto>(`/admin/dev-plan/versions/${row.id}`);
setEditing(detail);
form.setFieldsValue({
versionNo: detail.versionNo,
content: detail.content ?? '',
status: detail.status,
taskIds: detail.taskIds ?? [],
});
setModalOpen(true);
}
function openCreate() {
setEditing(null);
form.setFieldsValue({ versionNo: '', content: '', status: 'PENDING', taskIds: [] });
setModalOpen(true);
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const body = {
versionNo: values.versionNo.trim(),
content: values.content?.trim() || undefined,
status: values.status,
taskIds: values.taskIds ?? [],
};
if (editing) {
await request(`/admin/dev-plan/versions/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
message.success('已更新');
} else {
await request('/admin/dev-plan/versions', { method: 'POST', body: JSON.stringify(body) });
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function remove(id: string) {
try {
await request(`/admin/dev-plan/versions/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
const columns: ColumnsType<DevPlanVersionDto> = [
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: DevPlanVersionStatusDto) => (
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
),
},
{ title: '内容', dataIndex: 'content', ellipsis: true, render: (v) => v || '—' },
{ title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
{ title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
{ title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
{
title: '用时',
dataIndex: 'durationMinutes',
width: 100,
render: (v) => formatDuration(v),
},
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 120,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => void openEdit(row)}>
</Button>
<Popconfirm title="确认删除?(不删除关联任务)" onConfirm={() => void remove(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
·
</Typography.Title>
<Button type="primary" onClick={openCreate}>
</Button>
</div>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑版本' : '新建版本'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void save()}
confirmLoading={saving}
destroyOnClose
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="versionNo" label="版本号" rules={[{ required: true }]}>
<Input placeholder="如 v3.4.11" maxLength={32} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="content" label="内容">
<Input.TextArea placeholder="版本描述 / 更新日志" rows={3} maxLength={500} showCount />
</Form.Item>
<Form.Item name="taskIds" label="关联任务">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
options={allTasks.map((t) => ({
value: t.id,
label: `${t.taskNo} · ${t.content.slice(0, 40)}`,
}))}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
type Row = {
id: string;
eventType: string;
refType: string;
refId: string;
status?: string | null;
param1?: string | null;
param2?: string | null;
param3?: string | null;
remark?: string | null;
createdAt: string;
};
const EVENT_OPTIONS = [
{ value: '', label: '全部领域事件' },
{ value: 'ORDER_STATUS', label: '订单状态' },
{ value: 'BENEFIT_LEDGER', label: '权益流水' },
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'TICKET_COLLAB', label: '工单协作' },
{ value: 'PROMO_TOUCH', label: '推广触达' },
];
export default function DomainEventsPage() {
const [params, setParams] = useSearchParams();
const eventType = params.get('eventType') ?? '';
const refType = params.get('refType') ?? '';
const refId = params.get('refId') ?? '';
const [data, setData] = useState<{ items: Row[]; total: number; page: number; pageSize: number } | null>(null);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Row | null>(null);
const load = useCallback(() => {
setLoading(true);
const q = new URLSearchParams();
if (eventType) q.set('eventType', eventType);
if (refType) q.set('refType', refType);
if (refId) q.set('refId', refId);
q.set('page', params.get('page') ?? '1');
q.set('pageSize', '20');
request<{ items: Row[]; total: number; page: number; pageSize: number }>(
`/admin/logs/domain-events?${q.toString()}`,
)
.then(setData)
.finally(() => setLoading(false));
}, [eventType, refType, refId, params]);
useEffect(() => {
load();
}, [load]);
const columns: ColumnsType<Row> = [
{ 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: '状态', dataIndex: 'status', width: 100 },
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
{
title: '操作',
width: 80,
render: (_, r) => (
<a
onClick={async () => {
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
}}
>
</a>
),
},
];
return (
<Card title="领域事件">
<Space wrap style={{ marginBottom: 16 }}>
<Select
style={{ width: 180 }}
value={eventType}
options={EVENT_OPTIONS}
onChange={(v) => {
const next = new URLSearchParams(params);
if (v) next.set('eventType', v);
else next.delete('eventType');
next.set('page', '1');
setParams(next);
}}
/>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: data?.page ?? 1,
pageSize: data?.pageSize ?? 20,
total: data?.total ?? 0,
onChange: (p) => {
const next = new URLSearchParams(params);
next.set('page', String(p));
setParams(next);
},
}}
/>
<Drawer open={!!detail} title="事件详情" width={520} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small">
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</Card>
);
}
@@ -0,0 +1,371 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Divider,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
DEFAULT_XFX_LOGISTICS_PRICING,
FULFILLMENT_PROVIDER_STATUS_LABELS,
FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus,
FulfillmentProviderType,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
LogisticsSettlementMethod,
isXfxProviderCode,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
value,
label,
}));
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}));
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
([value, label]) => ({ value, label }),
);
const SIGN_OPTIONS = [
{ value: 'MD5', label: 'MD5' },
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
];
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
export default function FulfillmentProvidersPage() {
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
const [form] = Form.useForm();
const watchedCode = Form.useWatch('code', form);
const watchedType = Form.useWatch('type', form);
const showXfxFields = useMemo(
() => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API,
[watchedCode, watchedType],
);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
setRows(res);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function openCreate() {
setEditRow(null);
form.resetFields();
form.setFieldsValue({
code: 'XFX',
name: '小飞侠',
type: FulfillmentProviderType.API,
status: FulfillmentProviderStatus.ACTIVE,
apiUrl: DEFAULT_XFX_API_URL,
signType: 'MD5',
settlementMethod: LogisticsSettlementMethod.PREPAID,
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
});
setOpen(true);
}
function openEdit(row: FulfillmentProviderDto) {
setEditRow(row);
const xfx = row.xiaofeixiaConfig;
const pricing = row.pricingRules;
form.setFieldsValue({
code: row.code,
name: row.name,
type: row.type,
status: row.status,
apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL,
mchId: xfx?.mchId || '',
apiKey: '',
signType: xfx?.signType || 'MD5',
appId: xfx?.appId || '',
bankAccountName: row.bankAccountName || '',
bankName: row.bankName || '',
bankBranch: row.bankBranch || '',
bankAccountNo: row.bankAccountNo || '',
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
});
setOpen(true);
}
async function submit() {
const v = await form.validateFields();
const payload: Record<string, unknown> = {
name: v.name,
type: v.type,
status: v.status,
bankAccountName: v.bankAccountName || null,
bankName: v.bankName || null,
bankBranch: v.bankBranch || null,
bankAccountNo: v.bankAccountNo || null,
settlementMethod: v.settlementMethod,
pricingRules: {
baseBottles: Number(v.baseBottles),
baseFee: Number(v.baseFee),
extraBottleFee: Number(v.extraBottleFee),
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
},
};
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
payload.xiaofeixiaConfig = {
apiUrl: v.apiUrl,
mchId: v.mchId,
apiKey: v.apiKey || undefined,
signType: v.signType,
appId: v.appId || undefined,
};
}
if (editRow) {
await request(`/admin/fulfillment-providers/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('已更新');
} else {
await request('/admin/fulfillment-providers', {
method: 'POST',
body: JSON.stringify({
code: v.code,
...payload,
}),
});
message.success('已创建');
}
setOpen(false);
void load();
}
const columns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 },
{ title: '名称', dataIndex: 'name' },
{
title: '类型',
dataIndex: 'type',
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
},
{
title: '结算',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 100,
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
render: (v) => (
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
</Tag>
),
},
{
title: '接口配置',
render: (_, row) => {
if (isXfxProviderCode(row.code)) {
const cfg = row.xiaofeixiaConfig;
if (!cfg?.apiUrl) return <Tag></Tag>;
return (
<span title={cfg.apiUrl}>
{cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'}
</span>
);
}
return row.hasConfig ? '已配置' : '—';
},
},
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
/>
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
<Modal
title={editRow ? '编辑承运商' : '注册承运商'}
open={open}
onCancel={() => setOpen(false)}
onOk={() => void submit()}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如 小飞侠、京东物流" />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<Divider orientation="left"></Divider>
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
<Select options={SETTLEMENT_OPTIONS} />
</Form.Item>
<Form.Item name="bankAccountName" label="收款户名">
<Input />
</Form.Item>
<Form.Item name="bankName" label="开户银行">
<Input />
</Form.Item>
<Form.Item name="bankBranch" label="开户支行">
<Input />
</Form.Item>
<Form.Item name="bankAccountNo" label="银行账号">
<Input />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item
name="baseBottles"
label="起送瓶数"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="baseFee"
label="起送费用(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="extraBottleFee"
label="加一瓶(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
</Space>
{showXfxFields && (
<>
<Divider orientation="left"></Divider>
<Form.Item
name="apiUrl"
label="API 地址"
rules={[{ required: true, message: '请填写小飞侠 API 地址' }]}
extra="推单请求将发往此地址"
>
<Input placeholder={DEFAULT_XFX_API_URL} />
</Form.Item>
<Form.Item
name="mchId"
label="商户号"
rules={[{ required: true, message: '请填写商户号' }]}
>
<Input />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={editRow ? [] : [{ required: true, message: '请填写 API Key' }]}
extra={
editRow?.xiaofeixiaConfig?.hasApiKey
? '已配置密钥;留空则保持不变'
: undefined
}
>
<Input.Password placeholder={editRow ? '留空则不修改' : '请输入'} />
</Form.Item>
<Form.Item name="signType" label="签名类型" initialValue="MD5">
<Select options={SIGN_OPTIONS} />
</Form.Item>
<Form.Item name="appId" label="AppID(可选)">
<Input />
</Form.Item>
</>
)}
</Form>
</Modal>
</div>
);
}
+113 -34
View File
@@ -33,6 +33,15 @@ type AccountPermRes = {
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
function groupColSpan(itemCount: number): number {
if (itemCount <= 2) return 12;
if (itemCount === 3) return 8;
if (itemCount === 4) return 6;
return 8;
}
function PermissionChecklist({
value,
onChange,
@@ -49,13 +58,44 @@ function PermissionChecklist({
disabled={disabled}
onChange={(checked) => onChange(checked as string[])}
>
<Row gutter={[8, 8]}>
{HQ_PERMISSION_CATALOG.map((item) => (
<Col key={item.key} span={8}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
{CATALOG_GROUPS.map((group) => {
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
const span = groupColSpan(items.length);
const compact = items.length <= 3;
return (
<div
key={group}
style={{
marginBottom: 16,
padding: '12px 16px',
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
{group}
</Typography.Text>
{compact ? (
<Space size={[24, 8]} wrap>
{items.map((item) => (
<Checkbox key={item.key} value={item.key}>
{item.label}
</Checkbox>
))}
</Space>
) : (
<Row gutter={[12, 10]}>
{items.map((item) => (
<Col key={item.key} xs={24} sm={12} md={span}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
)}
</div>
);
})}
</Checkbox.Group>
);
}
@@ -74,6 +114,12 @@ export default function HqPermissionsPage() {
const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = useState(false);
const selectedAccount = useMemo(
() => accounts.find((a) => a.id === accountId),
[accounts, accountId],
);
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys],
@@ -158,7 +204,9 @@ export default function HqPermissionsPage() {
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
=
=
//
//
</Typography.Paragraph>
<Tabs
@@ -183,7 +231,11 @@ export default function HqPermissionsPage() {
</Form.Item>
</Form>
{role === 'SUPER_ADMIN' ? (
<Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" />
<Alert
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
/>
) : (
<>
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
@@ -212,12 +264,10 @@ export default function HqPermissionsPage() {
value={accountId}
onChange={setAccountId}
optionFilterProp="label"
options={accounts
.filter((a) => a.adminRole !== 'SUPER_ADMIN')
.map((a) => ({
value: a.id,
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
options={accounts.map((a) => ({
value: a.id,
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
/>
</Form.Item>
</Form>
@@ -225,28 +275,57 @@ export default function HqPermissionsPage() {
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : (
<>
<Space wrap style={{ marginBottom: 12 }}>
<span></span>
{roleInheritedKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return (
<Tag key={key} color="blue">
{item?.label || key}
</Tag>
);
})}
</Space>
<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>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
<Space wrap style={{ marginTop: 12 }}>
<span></span>
{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 style={{ marginTop: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Space wrap size={[4, 4]}>
<Tag></Tag>
{accountKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key} color="orange">
{item?.label || key}
</Tag>
);
})}
</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 style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
+487
View File
@@ -0,0 +1,487 @@
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
INVOICE_CATEGORY_LABELS,
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceCategory,
type InvoiceKind,
type InvoiceStatus,
type InvoiceTitleType,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
type Row = {
id: string;
invoiceNo: string;
orderNo?: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
status: InvoiceStatus;
overdue?: boolean;
createdAt: string;
fileUrl?: string;
email?: string;
phone?: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
payAmount?: string;
userPhone?: string;
remark?: string;
};
type CreateFormValues = {
orderNo: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
remark?: string;
};
export default function InvoicesPage() {
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>(
'/admin/invoices',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<CreateFormValues>();
const invoiceKind = Form.useWatch('invoiceKind', 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) {
setDetail(await request(`/admin/invoices/${id}`));
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) {
if (!detail) return false;
setUploading(true);
try {
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
await request(`/admin/invoices/${detail.id}/issue`, {
method: 'POST',
body: JSON.stringify({ fileUrl: uploaded.url }),
});
message.success('已开票回传');
reload();
setDrawerOpen(false);
} catch (e) {
message.error(e instanceof Error ? e.message : '开票失败');
} finally {
setUploading(false);
}
return false;
}
async function reject() {
if (!detail) return;
await request(`/admin/invoices/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ remark: '驳回' }),
});
message.success('已驳回');
reload();
setDrawerOpen(false);
}
async function submitCreate() {
const values = await createForm.validateFields();
setCreating(true);
try {
await request('/admin/invoices', {
method: 'POST',
body: JSON.stringify({
orderNo: values.orderNo.trim(),
titleType: values.titleType,
invoiceKind: values.invoiceKind,
invoiceCategory: values.invoiceCategory,
titleName: values.titleName.trim(),
taxNo: values.taxNo?.trim() || undefined,
addressPhone: values.addressPhone?.trim() || undefined,
bankAccount: values.bankAccount?.trim() || undefined,
email: values.email.trim(),
phone: values.phone.trim(),
remark: values.remark?.trim() || undefined,
}),
});
message.success('发票申请已创建');
setCreateOpen(false);
createForm.resetFields();
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
const columns: ColumnsType<Row> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{
title: '抬头',
width: 100,
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
},
{
title: '票种',
width: 140,
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
},
{
title: '类型',
width: 88,
render: (_, r) =>
r.invoiceCategory
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
: '—',
},
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
{
title: '状态',
width: 110,
render: (_, r) => (
<>
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
</Tag>
{r.overdue ? <Tag color="red"></Tag> : null}
</>
),
},
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.setFieldsValue({
titleType: 'PERSONAL',
invoiceKind: 'NORMAL',
invoiceCategory: 'LIQUOR',
});
setCreateOpen(true);
}}
>
</Button>
</div>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'PENDING', label: '待开票' },
{ value: 'ISSUED', label: '已开票' },
{ value: 'REJECTED', label: '已驳回' },
]}
/>
</Form.Item>
<Form.Item name="invoiceNo" label="申请单号">
<Input allowClear placeholder="发票申请单号" />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1000 }}
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="发票详情"
width={520}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Upload
accept="image/*,.pdf"
showUploadList={false}
beforeUpload={(file) => {
void issueWithFile(file);
return false;
}}
>
<Button type="primary" loading={uploading}>
</Button>
</Upload>
<Button danger onClick={() => void reject()}>
</Button>
</Space>
) : null
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="抬头类型">
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
</Descriptions.Item>
<Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]}
</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.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="状态">
{INVOICE_STATUS_LABELS[detail.status]}
{detail.overdue ? '(超 2 工作日)' : ''}
</Descriptions.Item>
<Descriptions.Item label="发票文件">
{detail.fileUrl ? (
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
/
</a>
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark ?? '—'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
<Modal
title="创建发票申请"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={creating}
destroyOnClose
okText="提交"
width={520}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="orderNo"
label="订单号"
rules={[{ required: true, message: '请填写已完成订单号' }]}
extra="仅已完成订单可开票"
>
<Input placeholder="订单号" allowClear />
</Form.Item>
<Form.Item
name="invoiceKind"
label="发票类型"
rules={[{ required: true, message: '请选择发票类型' }]}
>
<Select
options={(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => ({
value: k,
label: INVOICE_KIND_LABELS[k],
}))}
onChange={(k: InvoiceKind) => {
if (k === 'SPECIAL') createForm.setFieldValue('titleType', 'ENTERPRISE');
}}
/>
</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
name="titleType"
label="抬头类型"
rules={[{ required: true, message: '请选择抬头类型' }]}
>
<Select
options={(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => ({
value: t,
label: INVOICE_TITLE_TYPE_LABELS[t],
disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL',
}))}
/>
</Form.Item>
<Form.Item
name="titleName"
label="抬头名称"
rules={[{ required: true, message: '请填写抬头名称' }]}
>
<Input placeholder="个人姓名或企业全称" />
</Form.Item>
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
<Form.Item
name="taxNo"
label="税号"
rules={[{ required: true, message: '企业抬头须填写税号' }]}
>
<Input placeholder="纳税人识别号" />
</Form.Item>
)}
{invoiceKind === 'SPECIAL' && (
<>
<Form.Item
name="addressPhone"
label="地址电话"
rules={[{ required: true, message: '专用发票须填写地址电话' }]}
>
<Input placeholder="注册地址及电话" />
</Form.Item>
<Form.Item
name="bankAccount"
label="开户行账号"
rules={[{ required: true, message: '专用发票须填写开户行账号' }]}
>
<Input placeholder="开户行及账号" />
</Form.Item>
</>
)}
<Form.Item
name="email"
label="接收邮箱"
rules={[
{ required: true, message: '请填写邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input placeholder="发票发送邮箱" />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[{ required: true, message: '请填写手机号' }]}
>
<Input placeholder="联系手机" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,476 @@
import { useState } from 'react';
import {
Button,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { UploadOutlined } from '@ant-design/icons';
import type {
KnowledgeBaseDto,
KnowledgeDocumentDetailDto,
KnowledgeDocumentDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
type FormValues = {
name: string;
description?: string;
enabled: boolean;
};
type DocForm = {
title: string;
contentText?: string;
};
export default function KnowledgeBasesPage() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [docForm] = Form.useForm<DocForm>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeBaseDto | null>(null);
const [saving, setSaving] = useState(false);
const [drawerKb, setDrawerKb] = useState<KnowledgeBaseDto | null>(null);
const [docs, setDocs] = useState<KnowledgeDocumentDto[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
const [docModalOpen, setDocModalOpen] = useState(false);
const [docSaving, setDocSaving] = useState(false);
const [editingDoc, setEditingDoc] = useState<KnowledgeDocumentDto | null>(null);
const [editDocModalOpen, setEditDocModalOpen] = useState(false);
const [editDocSaving, setEditDocSaving] = useState(false);
const [editDocForm] = Form.useForm<DocForm>();
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<KnowledgeBaseDto>(
'/admin/knowledge-bases',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
const loadDocs = async (kb: KnowledgeBaseDto) => {
setDrawerKb(kb);
setDocsLoading(true);
try {
const res = await request<{ items: KnowledgeDocumentDto[] }>(
`/admin/knowledge-bases/${kb.id}/documents`,
);
setDocs(res.items);
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setDocsLoading(false);
}
};
const openCreate = () => {
setEditing(null);
form.setFieldsValue({ name: '', description: '', enabled: true });
setModalOpen(true);
};
const openEdit = (row: KnowledgeBaseDto) => {
setEditing(row);
form.setFieldsValue({
name: row.name,
description: row.description || '',
enabled: row.enabled,
});
setModalOpen(true);
};
const onSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await request(`/admin/knowledge-bases/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(values),
});
message.success('已保存');
} else {
await request('/admin/knowledge-bases', {
method: 'POST',
body: JSON.stringify(values),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const columns: ColumnsType<KnowledgeBaseDto> = [
{ title: '名称', dataIndex: 'name' },
{ title: '说明', dataIndex: 'description', ellipsis: true },
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
{
title: '启用',
dataIndex: 'enabled',
width: 90,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{
title: '创建人',
dataIndex: 'createdByName',
width: 100,
render: (v: string | null, row) => v || row.createdByHqAccountId,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 240,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => void loadDocs(row)}>
</Button>
{row.canEditFull ? (
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
) : null}
{row.canEditFull ? (
<Popconfirm
title="确认删除知识库及全部文档?"
onConfirm={async () => {
try {
await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
{ title: '标题', dataIndex: 'title' },
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) =>
s === 'READY' ? <Tag color="green"></Tag> : s === 'FAILED' ? <Tag color="red"></Tag> : <Tag></Tag>,
},
{
title: '时间',
dataIndex: 'createdAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 140,
render: (_, row) =>
drawerKb?.canEditFull ? (
<Space>
<Button
type="link"
size="small"
onClick={async () => {
if (!drawerKb) return;
try {
const detail = await request<KnowledgeDocumentDetailDto>(
`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`,
);
setEditingDoc(row);
editDocForm.setFieldsValue({
title: detail.title,
contentText: detail.contentText || '',
});
setEditDocModalOpen(true);
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
</Button>
<Popconfirm
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,
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
.txt/.md AI
</Typography.Text>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) =>
setFilters({
name: v.name || '',
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
})
}
>
<Form.Item name="name" label="名称">
<Input allowClear />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={openCreate}>
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items}
pagination={{
current: page,
pageSize,
total: data?.total,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑知识库' : '新建知识库'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Drawer
title={drawerKb ? `文档 · ${drawerKb.name}` : '文档'}
open={!!drawerKb}
width={720}
onClose={() => setDrawerKb(null)}
extra={
drawerKb?.canEditFull ? (
<Button type="primary" onClick={() => { docForm.resetFields(); setDocModalOpen(true); }}>
/
</Button>
) : null
}
>
<Table
rowKey="id"
size="small"
loading={docsLoading}
columns={docColumns}
dataSource={docs}
pagination={false}
/>
</Drawer>
<Modal
title="添加知识文档"
open={docModalOpen}
onCancel={() => setDocModalOpen(false)}
confirmLoading={docSaving}
onOk={async () => {
if (!drawerKb) return;
const values = await docForm.validateFields();
setDocSaving(true);
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
method: 'POST',
body: JSON.stringify({
title: values.title,
contentText: values.contentText || null,
}),
});
message.success('已添加');
setDocModalOpen(false);
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setDocSaving(false);
}
}}
destroyOnClose
width={640}
>
<Form form={docForm} 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.Item label="或上传文本文件">
<Upload
accept=".txt,.md,.markdown,.csv,.json,.log,text/*"
maxCount={1}
customRequest={async ({ file, onSuccess, onError }) => {
if (!drawerKb) return;
try {
const raw = file as File;
const uploaded = await uploadFileToOss(raw, {
bizType: 'KB_DOCUMENT',
mediaType: 'FILE',
});
const title =
docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, '');
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
method: 'POST',
body: JSON.stringify({
title,
fileName: raw.name,
fileUrl: uploaded.url,
mimeType: raw.type || null,
sizeBytes: raw.size,
}),
});
message.success('上传成功');
setDocModalOpen(false);
void loadDocs(drawerKb);
reload();
onSuccess?.(uploaded);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
message.error(err.message);
onError?.(err);
}
}}
showUploadList={false}
>
<Button icon={<UploadOutlined />}> OSS </Button>
</Upload>
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑知识文档"
open={editDocModalOpen}
onCancel={() => {
setEditDocModalOpen(false);
setEditingDoc(null);
}}
confirmLoading={editDocSaving}
onOk={async () => {
if (!drawerKb || !editingDoc) return;
const values = await editDocForm.validateFields();
setEditDocSaving(true);
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${editingDoc.id}`, {
method: 'PUT',
body: JSON.stringify({
title: values.title,
contentText: values.contentText || null,
}),
});
message.success('已保存');
setEditDocModalOpen(false);
setEditingDoc(null);
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setEditDocSaving(false);
}
}}
destroyOnClose
width={640}
>
<Form form={editDocForm} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contentText" label="正文(可粘贴)">
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+380
View File
@@ -0,0 +1,380 @@
import { useEffect, useState } from 'react';
import {
Button,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
LLM_PROVIDERS,
LLM_PROVIDER_LABELS,
LLM_PROVIDER_PRESETS,
type LlmApiConfigDto,
type LlmProvider,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type FormValues = {
name: string;
provider: LlmProvider;
baseUrl?: string;
apiKey?: string;
modelName?: string;
temperature?: number | null;
maxTokens?: number | null;
systemPrompt?: string;
enabled: boolean;
};
export default function LlmConfigsPage() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<LlmApiConfigDto | null>(null);
const [saving, setSaving] = useState(false);
const providerWatch = Form.useWatch('provider', form);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
'/admin/llm-configs',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
useEffect(() => {
if (!providerWatch || editing) return;
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
if (!preset) return;
form.setFieldsValue({
baseUrl: preset.defaultBaseUrl || undefined,
modelName: preset.defaultModel || undefined,
});
}, [providerWatch, editing, form]);
const openCreate = () => {
setEditing(null);
form.setFieldsValue({
name: '',
provider: 'DEEPSEEK',
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
apiKey: '',
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
temperature: 0.3,
maxTokens: 1024,
systemPrompt: '',
enabled: true,
});
setModalOpen(true);
};
const openEdit = (row: LlmApiConfigDto) => {
setEditing(row);
form.setFieldsValue({
name: row.name,
provider: row.provider,
baseUrl: row.baseUrl,
apiKey: '',
modelName: row.modelName,
temperature: row.temperature,
maxTokens: row.maxTokens,
systemPrompt: row.systemPrompt || '',
enabled: row.enabled,
});
setModalOpen(true);
};
const onSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
if (!editing.canEditFull) {
await request(`/admin/llm-configs/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: values.enabled }),
});
} else {
await request(`/admin/llm-configs/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({
name: values.name,
provider: values.provider,
baseUrl: values.baseUrl,
apiKey: values.apiKey || undefined,
modelName: values.modelName,
temperature: values.temperature ?? null,
maxTokens: values.maxTokens ?? null,
systemPrompt: values.systemPrompt || null,
enabled: values.enabled,
}),
});
}
message.success('已保存');
} else {
await request('/admin/llm-configs', {
method: 'POST',
body: JSON.stringify({
name: values.name,
provider: values.provider,
baseUrl: values.baseUrl,
apiKey: values.apiKey,
modelName: values.modelName,
temperature: values.temperature ?? null,
maxTokens: values.maxTokens ?? null,
systemPrompt: values.systemPrompt || null,
enabled: values.enabled,
}),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const columns: ColumnsType<LlmApiConfigDto> = [
{ title: '名称', dataIndex: 'name' },
{
title: '提供商',
dataIndex: 'provider',
width: 120,
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
},
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
{
title: 'Key',
dataIndex: 'apiKeyConfigured',
width: 80,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{
title: '生效',
dataIndex: 'enabled',
width: 90,
render: (v: boolean, row) => (
<Switch
checked={v}
disabled={!row.isOwner && !row.canEditFull}
onChange={async (checked) => {
try {
await request(`/admin/llm-configs/${row.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: checked }),
});
message.success(checked ? '已启用' : '已停用');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
/>
),
},
{
title: '创建人',
dataIndex: 'createdByName',
width: 100,
render: (v: string | null, row) => v || row.createdByHqAccountId,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space>
<Button
type="link"
size="small"
onClick={() => openEdit(row)}
>
{row.canEditFull ? '编辑' : '开关'}
</Button>
<Button
type="link"
size="small"
onClick={async () => {
try {
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
method: 'POST',
body: '{}',
});
message.success(res.reply || '测试成功');
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
</Button>
{row.canEditFull ? (
<Popconfirm
title="确认删除?"
onConfirm={async () => {
try {
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const fullEdit = !editing || editing.canEditFull;
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) =>
setFilters({
name: v.name || '',
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
})
}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="搜索" />
</Form.Item>
<Form.Item name="enabled" label="生效">
<Select
allowClear
style={{ width: 100 }}
options={[
{ value: true, label: '开' },
{ value: false, label: '关' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={openCreate}>
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items}
pagination={{
current: page,
pageSize,
total: data?.total,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
<Input disabled={!fullEdit} />
</Form.Item>
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
<Select
disabled={!fullEdit}
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
/>
</Form.Item>
<Form.Item
name="baseUrl"
label="Base URL"
rules={[{ required: fullEdit, message: '必填' }]}
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1"
>
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={editing ? [] : [{ required: true, message: '必填' }]}
extra={editing ? '留空表示不修改' : undefined}
>
<Input.Password disabled={!fullEdit} />
</Form.Item>
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
<Input disabled={!fullEdit} />
</Form.Item>
<Space style={{ display: 'flex' }} size="large">
<Form.Item name="temperature" label="Temperature">
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="maxTokens" label="Max Tokens">
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
</Form.Item>
</Space>
<Form.Item name="systemPrompt" label="系统提示词">
<Input.TextArea disabled={!fullEdit} rows={3} />
</Form.Item>
<Form.Item name="enabled" label="生效" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
import { saveAuth, request } from '../lib/api';
type LoginResult = { accessToken: string; refreshToken: string };
@@ -117,14 +118,14 @@ export default function LoginPage() {
form={smsForm}
layout="vertical"
onFinish={onSmsFinish}
initialValues={{ phone: '13600000001', code: '123456' }}
initialValues={{ phone: '13600000001', code: MOCK_SMS_FIXED_CODE }}
>
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
<Input placeholder="13600000001" maxLength={11} />
</Form.Item>
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
<Input
placeholder="123456"
placeholder={MOCK_SMS_FIXED_CODE}
addonAfter={
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
@@ -0,0 +1,625 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
LOGISTICS_SETTLEMENT_METHOD_LABELS,
type LogisticsSettlementMethod,
} from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type BillRow = {
id: string;
billNo: string;
fulfillmentProviderId: string;
providerCode?: string;
providerName?: string;
periodStart: string;
periodEnd: string;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
settlementMethod: string;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
quantity: number;
logisticsAmount: number;
shippedAt: string;
};
type ProviderSummary = {
providerId: string;
providerCode: string;
providerName: string;
settlementMethod: string;
prepaidBalance: number;
bankAccountName?: string | null;
bankName?: string | null;
bankAccountNo?: string | null;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
billId?: string | null;
billStatus?: string | null;
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
};
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未结算',
PAID: '已结算',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
};
export default function LogisticsBillsPage() {
const [form] = Form.useForm();
const [summaryForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
'/admin/logistics-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs;
},
[filters],
);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
const [rechargeForm] = Form.useForm();
useEffect(() => {
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
.catch(() => {});
}, []);
async function loadSummary(period: Dayjs) {
setSummaryLoading(true);
try {
const res = await request<{ items: ProviderSummary[] }>(
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
);
setSummaryRows(res.items ?? []);
} finally {
setSummaryLoading(false);
}
}
useEffect(() => {
void loadSummary(summaryPeriod);
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
okText: '确认结算',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已确认结算');
setSelectedKeys([]);
reload();
void loadSummary(summaryPeriod);
},
});
}
async function openDetail(id: string) {
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
const result = await request<{ csv: string; count: number }>(
`/admin/logistics-bills/export?${qs}`,
);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
async function generateBills() {
const v = await genForm.validateFields();
const month = v.month as Dayjs;
await request('/admin/logistics-bills/generate', {
method: 'POST',
body: JSON.stringify({
year: month.year(),
month: month.month() + 1,
providerId: v.providerId || undefined,
}),
});
message.success('已生成物流对账单');
setGenOpen(false);
reload();
void loadSummary(summaryPeriod);
}
async function submitRecharge() {
if (!rechargeTarget) return;
const v = await rechargeForm.validateFields();
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
method: 'POST',
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
});
message.success('充值成功');
setRechargeOpen(false);
void loadSummary(summaryPeriod);
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{
title: '承运商',
width: 140,
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
<Button
type="link"
size="small"
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
>
</Button>
)}
</Space>
),
},
];
const summaryColumns: ColumnsType<ProviderSummary> = [
{
title: '承运商',
render: (_, r) => `${r.providerName} (${r.providerCode})`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 110,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{
title: '计价标准',
width: 220,
render: (_, r) => {
const p = r.pricingRules;
if (!p) return '—';
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
p.boxBottles ? `${p.boxBottles}瓶箱¥${p.boxFee}` : ''
}`;
},
},
{
title: '银行账户',
width: 180,
ellipsis: true,
render: (_, r) =>
r.bankAccountName
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
: '—',
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '应付物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '账单',
width: 100,
render: (_, r) =>
r.billStatus ? (
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
{STATUS_LABELS[r.billStatus] || r.billStatus}
</Tag>
) : (
'未生成'
),
},
{
title: '操作',
width: 100,
render: (_, r) =>
r.settlementMethod === 'PREPAID' ? (
<Button
type="link"
size="small"
onClick={() => {
setRechargeTarget(r);
rechargeForm.resetFields();
setRechargeOpen(true);
}}
>
</Button>
) : null,
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Tabs
items={[
{
key: 'summary',
label: '按承运商汇总',
children: (
<>
<Form
form={summaryForm}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={{ month: summaryPeriod }}
onFinish={(v: { month?: Dayjs }) => {
const m = v.month || dayjs().subtract(1, 'month');
setSummaryPeriod(m);
}}
>
<Form.Item name="month" label="账期">
<DatePicker picker="month" allowClear={false} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="providerId"
loading={summaryLoading}
columns={summaryColumns}
dataSource={summaryRows}
pagination={false}
scroll={{ x: 1200 }}
/>
</>
),
},
{
key: 'bills',
label: '月账单',
children: (
<>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic
title="瓶数合计"
value={summary.bottleCount ?? 0}
/>
<Statistic
title="物流费合计"
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
prefix="¥"
precision={2}
/>
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: {
status?: string;
providerId?: string;
month?: Dayjs;
}) => {
setFilters({
status: v.status || '',
providerId: v.providerId || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'UNPAID', label: '未结算' },
{ value: 'PAID', label: '已结算' },
]}
/>
</Form.Item>
<Form.Item name="providerId" label="承运商">
<Select
allowClear
style={{ width: 180 }}
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期">
<DatePicker picker="month" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
setGenOpen(true);
}}
>
</Button>
<Button loading={exporting} onClick={() => void exportExcel()}>
</Button>
<Button
disabled={!selectedKeys.length}
onClick={() =>
confirmPay(
selectedKeys.map(String),
selectedAmount,
)
}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={billColumns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 1100 }}
/>
</>
),
},
]}
/>
<Drawer
title="物流对账明细"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail && (
<>
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="承运商">
{detail.providerName} ({detail.providerCode})
</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="物流费">
¥{Number(detail.logisticsAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
</Descriptions>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '发货时间',
dataIndex: 'shippedAt',
width: 160,
render: fmtTime,
},
]}
/>
</>
)}
</Drawer>
<Modal
title="生成物流月账单"
open={genOpen}
onCancel={() => setGenOpen(false)}
onOk={() => void generateBills()}
destroyOnClose
>
<Form form={genForm} layout="vertical">
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="providerId" label="承运商(空=全部)">
<Select
allowClear
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
open={rechargeOpen}
onCancel={() => setRechargeOpen(false)}
onOk={() => void submitRecharge()}
destroyOnClose
>
<Form form={rechargeForm} layout="vertical">
<Form.Item
name="amount"
label="金额"
rules={[{ required: true, message: '请输入充值金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="可选,如预充运费" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) {
setSubParent(parent);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
+177 -32
View File
@@ -4,6 +4,7 @@ import {
Card,
DatePicker,
Form,
Input,
Modal,
Select,
Space,
@@ -29,6 +30,7 @@ type Row = {
status: string;
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
};
@@ -36,15 +38,19 @@ type Row = {
type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
CONFIRMED: '确认',
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default',
CONFIRMED: 'blue',
PENDING_REVIEW: 'gold',
AWAITING_CONFIRM: 'blue',
UNPAID: 'red',
PAID: 'green',
REJECTED: 'red',
};
export default function PartnerBillsPage() {
@@ -66,6 +72,11 @@ export default function PartnerBillsPage() {
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -73,10 +84,10 @@ export default function PartnerBillsPage() {
.catch(() => {});
}, []);
async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
const year = values.month.year();
const month = values.month.month() + 1;
if (values.all || !values.partnerId) {
if (!values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) },
@@ -87,25 +98,88 @@ export default function PartnerBillsPage() {
method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
});
message.success('账单已生成');
message.success('账单已生成(待审核)');
}
setGenOpen(false);
reload();
}
async function confirmBill(id: string) {
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
message.success('已确认');
reload();
function sendBills(ids: string[]) {
Modal.confirm({
title: '发送给合伙人?',
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
okText: '确认发送',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
} else {
await request('/admin/partner-bills/batch-send', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已发送');
setSelectedKeys([]);
reload();
},
});
}
async function markPaid(id: string) {
await request(`/admin/partner-bills/${id}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
function markPaid(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认打款?',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
} else {
await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已标记打款');
setSelectedKeys([]);
reload();
},
});
message.success('已标记打款');
reload();
}
function openReject(row: Row) {
Modal.confirm({
title: '驳回该账单?',
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
okText: '继续填写理由',
cancelText: '取消',
onOk: () => {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
},
});
}
async function submitReject(values: { reason: string }) {
if (!rejectTarget) return;
setRejecting(true);
try {
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: values.reason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setRejectTarget(null);
reload();
} finally {
setRejecting(false);
}
}
async function exportExcel() {
@@ -127,6 +201,10 @@ export default function PartnerBillsPage() {
const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
@@ -163,23 +241,42 @@ export default function PartnerBillsPage() {
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
width: 130,
render: (s, row) => (
<Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
{s === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
),
},
{
title: '操作',
width: 160,
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
{row.status === 'DRAFT' && (
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
<Space size={0} wrap>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
</Button>
)}
{row.status === 'CONFIRMED' && (
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
{row.status === 'UNPAID' && (
<>
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
</Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
</>
)}
{row.status === 'AWAITING_CONFIRM' && (
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
)}
</Space>
@@ -194,7 +291,7 @@ export default function PartnerBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+30 × ×
1 8:00
</Typography.Text>
</Space>
@@ -242,7 +339,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
style={{ width: 150 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
@@ -272,6 +369,20 @@ export default function PartnerBillsPage() {
Excel
</Button>
</Form.Item>
<Form.Item>
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
({canSend.length})
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!canPay.length}
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
>
({canPay.length})
</Button>
</Form.Item>
</Form>
<Table
@@ -280,7 +391,11 @@ export default function PartnerBillsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
}}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
@@ -293,7 +408,7 @@ export default function PartnerBillsPage() {
}}
/>
<Modal title="生成合伙人账单T+30" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form
form={genForm}
layout="vertical"
@@ -316,7 +431,37 @@ export default function PartnerBillsPage() {
/>
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
<Modal
title="驳回打款申请"
open={rejectOpen}
onCancel={() => {
setRejectOpen(false);
setRejectTarget(null);
}}
footer={null}
destroyOnClose
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
</Typography.Paragraph>
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
<Form.Item
name="reason"
label="驳回理由"
rules={[
{ required: true, message: '请填写驳回理由' },
{ max: 500, message: '不超过 500 字' },
]}
>
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
</Form.Item>
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
</Button>
</Form>
</Modal>
+80 -37
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Cascader,
Drawer,
Form,
Input,
@@ -24,9 +23,10 @@ import {
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
type Row = {
id: string;
@@ -37,6 +37,7 @@ type Row = {
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
storeCount: number;
@@ -102,6 +103,18 @@ export default function PartnersPage() {
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
@@ -142,22 +155,35 @@ export default function PartnersPage() {
async function savePartner() {
if (!detail) return;
const v = await editForm.validateFields();
const body = {
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
};
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
message.success('已保存');
setDrawerOpen(false);
void reload();
try {
const v = await editForm.validateFields();
const body = {
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
};
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 100 },
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 130 },
{
title: '管辖',
@@ -222,17 +248,21 @@ export default function PartnersPage() {
label: '基本信息',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县">
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
@@ -286,39 +316,52 @@ export default function PartnersPage() {
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
try {
const v = await createForm.validateFields();
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}
>
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }}
onChange={(id) => {
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
@@ -34,7 +34,7 @@ function mapToForm(d: Record<string, unknown>) {
const row = d as Row;
return {
...row,
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
features: row.features?.length
? row.features
: [{ icon: 'star', title: '', desc: '' }],
@@ -68,12 +68,14 @@ function buildPayload(v: TemplateFormValues) {
function TemplateContentFields() {
return (
<>
<Typography.Text type="secondary"></Typography.Text>
<DetailImageUrlList
label="详情图"
bizType="DETAIL_TEMPLATE"
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
/>
<Typography.Text type="secondary"></Typography.Text>
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
<DetailImageUrlList
label="详情图"
bizType="DETAIL_TEMPLATE"
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
/>
</Form.Item>
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
@@ -228,7 +230,7 @@ export default function ProductDetailTemplatesPage() {
}} width={640}>
<Form form={createForm} layout="vertical" initialValues={{
status: 'ACTIVE', sortOrder: 0,
detailImageUrls: [''],
detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
+166 -42
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Table, Tabs, Tag, Typography, message,
Switch, Table, Tabs, Tag, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
@@ -9,8 +9,10 @@ import { request } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form';
type ProductDetailContentDto = {
@@ -31,6 +33,11 @@ type Row = {
benefitAmount: number;
status: string;
sortOrder: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
mainImageUrl?: string | null;
carouselUrls?: string[];
detailImageUrls?: string[];
@@ -49,6 +56,11 @@ type ProductFormValues = {
benefitAmount?: number;
status?: string;
sortOrder?: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
coverUrl?: string;
carouselUrls?: string[];
detailImageUrls?: string[];
@@ -59,11 +71,17 @@ type ProductFormValues = {
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
allowOnlinePurchase: row.allowOnlinePurchase !== false,
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
allowOnSitePickup: !!row.allowOnSitePickup,
visibilityWhitelistEnabled: !!row.visibilityWhitelistEnabled,
visibilityPhones: (row.visibilityPhones ?? []) as string[],
storyTitle: detail.storyTitle ?? '',
storyText: detail.storyText ?? '',
features: detail.features?.length
@@ -90,7 +108,6 @@ function buildProductPayload(v: ProductFormValues) {
};
return {
skuCode: v.skuCode,
barcode69: v.barcode69,
name: v.name,
subtitle: v.subtitle,
@@ -100,6 +117,11 @@ function buildProductPayload(v: ProductFormValues) {
benefitAmount: v.benefitAmount,
status: v.status,
sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
allowOnlinePurchase: v.allowOnlinePurchase !== false,
allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
@@ -109,25 +131,13 @@ function buildProductPayload(v: ProductFormValues) {
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
</>
)}
</Form.List>
<Form.Item name={name} style={{ marginBottom: 0 }}>
<MultiImageUpload
bizType={bizType}
mediaType="IMAGE"
tip={`${label}支持一次选择多张批量上传`}
/>
</Form.Item>
);
}
@@ -136,11 +146,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
<>
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
<Divider />
<Typography.Text type="secondary">CAROUSEL</Typography.Text>
<Typography.Text type="secondary">CAROUSEL 10MB</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider />
<Typography.Text type="secondary">DETAIL</Typography.Text>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
<Typography.Text type="secondary">DETAIL 10MB</Typography.Text>
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
</Form.Item>
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
@@ -180,16 +192,43 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
);
}
function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
<>
<Form.Item
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
</Typography.Text>
) : null}
</>
);
}
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
return (
<>
{mode === 'create' && (
<>
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
<Input />
<Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" />
</Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
<Input />
<Form.Item
name="barcode69"
label="69码"
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
>
<Input placeholder="默认规格 69 码" />
</Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
@@ -217,6 +256,40 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="allowOnlinePurchase"
label="允许线上购买"
valuePropName="checked"
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
name="allowCrossCityDelivery"
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} />
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
@@ -258,14 +331,38 @@ export default function ProductsPage() {
}
}
const columns: ColumnsType<Row> = [
const columns: 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: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) },
{
title: '白名单',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v: boolean) =>
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: 'createdAt', width: 160, render: fmtTime },
{
@@ -291,7 +388,7 @@ export default function ProductsPage() {
</Space>
),
},
];
], [detail, editForm]);
return (
<div>
@@ -309,12 +406,16 @@ export default function ProductsPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1140 }}
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
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={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
@@ -325,13 +426,13 @@ export default function ProductsPage() {
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</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>
<Form form={editForm} layout="vertical">
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
{
key: 'detail',
label: '详情页',
@@ -342,6 +443,23 @@ export default function ProductsPage() {
/>
),
},
{
key: 'specs',
label: '规格与 SKU',
children: (
<ProductSpecsEditor
productId={String(detail.id)}
initialAttrs={(detail.specAttrs as never) ?? []}
initialSkus={(detail.skus as never) ?? []}
onSaved={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
void reload();
}}
/>
),
},
]} />
</Form>
</>
@@ -349,6 +467,10 @@ export default function ProductsPage() {
</Drawer>
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
@@ -358,11 +480,13 @@ export default function ProductsPage() {
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
carouselUrls: [''], detailImageUrls: [''],
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [], detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" form={createForm} /> },
{
key: 'detail',
label: '详情页',
+244 -236
View File
@@ -1,236 +1,244 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
type PromoCodeItem,
type PromoCodeScene,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = PromoCodeItem;
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() {
const navigate = useNavigate();
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
label,
})),
);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
return qs;
},
[filters],
);
async function loadScenes() {
try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
if (list.length) setScenes(list);
} catch {
/* 使用本地默认场景 */
}
}
useEffect(() => {
void loadScenes();
}, []);
const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
dataIndex: 'scene',
width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
</Tag>
),
},
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
{
title: '渠道负责人',
width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button>
<Button
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
type PromoCodeItem,
type PromoCodeScene,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = PromoCodeItem;
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() {
const navigate = useNavigate();
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
label,
})),
);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
return qs;
},
[filters],
);
async function loadScenes() {
try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
if (list.length) setScenes(list);
} catch {
/* 使用本地默认场景 */
}
}
useEffect(() => {
void loadScenes();
}, []);
const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
dataIndex: 'scene',
width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
</Tag>
),
},
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
{
title: '渠道负责人',
width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</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>
</Space>
),
},
];
async function handleCreate(values: Record<string, string>) {
setCreating(true);
try {
const created = await request<PromoCodeItem>('/admin/promo-codes', {
method: 'POST',
body: JSON.stringify({
name: values.name,
code: values.code?.trim() || undefined,
scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}),
});
message.success('推广码已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
navigate(`/promo-codes/${created.id}`);
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>
</div>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => { setFilters(v); setPage(1); }}
>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
<Form.Item name="scene" label="场景">
<Select allowClear style={{ width: 130 }} options={scenes} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Modal
title="创建推广码"
open={createOpen}
onCancel={() => setCreateOpen(false)}
footer={null}
destroyOnClose
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
<Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:郑州品鉴会、门店地推" />
</Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
<Select options={scenes} />
</Form.Item>
<Form.Item name="code" label="自定义码值(选填)">
<Input placeholder="留空自动生成,如 DKDEMO1" />
</Form.Item>
<Form.Item
name="page"
label="小程序落地页(选填)"
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Input placeholder="pages/home/index" />
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
</Button>
</Form>
</Modal>
</div>
);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
} from 'antd';
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
import { request } from '../lib/api';
type ApiResult = Record<string, unknown>;
@@ -255,7 +256,7 @@ export default function RedeemDebugPage() {
<Input />
</Form.Item>
<Form.Item name="code" label="验证码" rules={[{ required: true }]}>
<Input placeholder="Mock 默认 123456" />
<Input placeholder={`Mock 默认 ${MOCK_SMS_FIXED_CODE}`} />
</Form.Item>
<Button
loading={loading}
@@ -335,7 +336,7 @@ export default function RedeemDebugPage() {
<Input />
</Form.Item>
<Form.Item name="code" label="核销确认验证码" rules={[{ required: true }]}>
<Input placeholder="Mock 默认 123456" />
<Input placeholder={`Mock 默认 ${MOCK_SMS_FIXED_CODE}`} />
</Form.Item>
<Button
type="primary"
+165 -28
View File
@@ -1,48 +1,134 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string; redeemNo: string; amount: number; settleAmount: number; createdAt: string;
user?: { userNo: string; phone: string | null };
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
channel?: RedeemChannel;
createdAt: string;
isTest?: boolean;
user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
};
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? '—';
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
export default function RedeemRecordsPage() {
const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
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>(
'/admin/redeem-records',
() => {
const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
if (filters.storeId) qs.set('storeId', String(filters.storeId));
if (filters.channel) qs.set('channel', String(filters.channel));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
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 columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
{
title: '核销号',
dataIndex: 'redeemNo',
width: 200,
render: (v, row) => (
<span>
{v}
{row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}>
</Tag>
) : null}
</span>
),
},
{
title: '方式',
dataIndex: 'channel',
width: 110,
render: (v: RedeemChannel | undefined) => {
const channel = v === 'PHONE' ? 'PHONE' : 'SCAN';
return (
<Tag color={channel === 'PHONE' ? 'purple' : 'blue'}>
{REDEEM_CHANNEL_LABELS[channel]}
</Tag>
);
},
},
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
{
title: '用户昵称',
dataIndex: ['user', 'nickname'],
width: 100,
render: (v: string | null | undefined) => v || '—',
},
{
title: '用户手机',
dataIndex: ['user', 'phone'],
width: 120,
render: (v: string | null | undefined) => maskPhone(v),
},
{ title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/redeem-records/${row.id}`));
setDrawerOpen(true);
}}></Button>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
),
},
];
@@ -50,21 +136,72 @@ export default function RedeemRecordsPage() {
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="redeemNo" label="核销号"><Input allowClear /></Form.Item>
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="redeemNo" label="核销号">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店ID">
<Input allowClear />
</Form.Item>
<Form.Item name="channel" label="方式">
<Select
allowClear
style={{ width: 140 }}
options={[
{ value: 'SCAN', label: '扫码核销' },
{ value: 'PHONE', label: '手机号核销' },
]}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions>
)}
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1300 }}
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);
setDetail(null);
}}
destroyOnClose
>
{detailLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : detail ? (
<RedeemRecordDetailDescriptions detail={detail} />
) : null}
</Drawer>
</div>
);
+64 -6
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
@@ -15,6 +15,7 @@ type Row = {
name: string;
status: string;
createdAt: string;
isTest?: boolean;
storeCount?: number;
staffCount?: number;
bankAccountName?: string | null;
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts',
() => {
const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone);
if (filters.status) qs.set('status', filters.status);
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.status) qs.set('status', String(filters.status));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -45,14 +47,45 @@ export default function StoreAccountsPage() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items);
}
async function refreshDetail(accountId: string) {
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
setDetail(d);
void reload();
}
async function deleteStaff(staffId: string) {
if (!detail) return;
setDeletingStaffId(staffId);
try {
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshDetail(detail.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeletingStaffId(null);
}
}
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '绑定门店',
@@ -141,6 +174,9 @@ export default function StoreAccountsPage() {
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
@@ -218,10 +254,32 @@ export default function StoreAccountsPage() {
dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
]}
/>
</>
) : null}
) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
</>
)}
</Drawer>
+419 -102
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Card,
@@ -6,6 +7,8 @@ import {
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Statistic,
@@ -16,44 +19,66 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import {
STORE_SETTLEMENT_KIND_LABELS,
STORE_SETTLEMENT_STATUS_LABELS,
storeSettlementStatusLabel,
type StoreSettlementKind,
type StoreSettlementStatus,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
type Row = {
kind: StoreSettlementKind;
id: string;
redeemAmount: number;
payoutAmount: number;
settlementRate: number;
status: string;
expectedPayAt: string;
paidAt?: string;
createdAt: string;
store?: { id: string; name: string; cityName: string; phone?: string };
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
billNo: string;
amount: number;
status: StoreSettlementStatus;
date: string;
overdue?: boolean;
redeemCount?: number | null;
redeemAmount?: number | null;
settlementRate?: number | null;
payoutCount?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string | null };
};
type StoreOption = { id: string; name: string; phone: string };
const PAYOUT_STATUS_LABELS: Record<string, string> = {
PENDING: '待打款',
PAID: '已打款',
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
PENDING_REVIEW: 'orange',
REJECTED: 'red',
};
const PAYOUT_STATUS_COLORS: Record<string, string> = {
PENDING: 'orange',
PAID: 'green',
const KIND_COLORS: Record<StoreSettlementKind, string> = {
T1_BILL: 'blue',
WITHDRAW: 'purple',
};
export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
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 [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialStatus,
storeId: initialStoreId,
});
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-payouts',
'/admin/store-settlements',
() => {
const qs = new URLSearchParams();
if (filters.kind) qs.set('kind', filters.kind);
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
@@ -63,22 +88,132 @@ export default function StoreBillsPage() {
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [detailKind, setDetailKind] = useState<StoreSettlementKind | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [overdueSummary, setOverdueSummary] = useState<{
pendingCount: number;
overdueCount: number;
} | null>(null);
useEffect(() => {
form.setFieldsValue({
kind: filters.kind || undefined,
status: filters.status || undefined,
storeId: filters.storeId || undefined,
});
}, [filters.kind, filters.status, filters.storeId, form]);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setStores(res.items))
.catch(() => {});
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
}, []);
async function confirmPayout(id: string) {
await request(`/admin/store-payouts/${id}/confirm`, {
method: 'POST',
body: JSON.stringify({ remark: '财务确认打款' }),
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
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();
},
});
message.success('已确认打款');
reload();
}
function approveWithdraw(id: string) {
let paymentRef = '';
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) {
let reason = '';
Modal.confirm({
title: '驳回提现申请?',
content: (
<Input.TextArea
rows={3}
placeholder="请填写驳回理由"
onChange={(e) => {
reason = e.target.value;
}}
/>
),
okText: '确认驳回',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (!reason.trim()) {
message.error('请填写驳回理由');
throw new Error('reason required');
}
await request(`/admin/store-withdrawals/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason.trim() }),
});
message.success('已驳回');
setDrawerOpen(false);
reload();
},
});
}
async function openDetail(row: Row) {
const path =
row.kind === 'WITHDRAW' ? `/admin/store-withdrawals/${row.id}` : `/admin/store-bills/${row.id}`;
const d = await request<Record<string, unknown>>(path);
setDetail(d);
setDetailKind(row.kind);
setDrawerOpen(true);
}
async function exportExcel() {
@@ -89,81 +224,114 @@ export default function StoreBillsPage() {
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(
`/admin/store-payouts/export?${qs}`,
);
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`);
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count} 条 T+1 账单`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter(
(r) =>
selectedKeys.includes(`${r.kind}-${r.id}`) &&
r.kind === 'T1_BILL' &&
r.status === 'UNPAID',
);
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
const columns: ColumnsType<Row> = [
{
title: '核销日期',
width: 110,
render: (_, r) =>
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
title: '类型',
dataIndex: 'kind',
width: 100,
render: (k: StoreSettlementKind) => (
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
),
},
{
title: '单号',
dataIndex: 'billNo',
width: 180,
ellipsis: true,
render: (v, row) => (
<Space>
<span>{v}</span>
{row.overdue ? <Tag color="magenta"></Tag> : null}
</Space>
),
},
{
title: '日期',
dataIndex: 'date',
width: 160,
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', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{
title: '核销单号',
dataIndex: ['redeemRecord', 'redeemNo'],
width: 160,
ellipsis: true,
render: (v) => v || '—',
},
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{
title: '核销比例',
dataIndex: 'settlementRate',
width: 90,
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
title: '笔数',
width: 80,
render: (_, row) =>
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
},
{
title: '应付门店',
dataIndex: 'payoutAmount',
width: 100,
render: (v) => `¥${v}`,
title: '应付金额',
dataIndex: 'amount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '打款状态',
title: '状态',
dataIndex: 'status',
width: 100,
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
render: (s: string, row) => (
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
),
},
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{
title: '操作',
width: 140,
width: 200,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-payouts/${row.id}`));
setDrawerOpen(true);
}}
>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
{row.status === 'PENDING' && (
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
{row.kind === 'T1_BILL' && row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.amount))}>
</Button>
)}
{row.kind === 'WITHDRAW' && row.status === 'PENDING_REVIEW' && (
<>
<Button type="link" size="small" onClick={() => approveWithdraw(row.id)}>
</Button>
<Button type="link" size="small" danger onClick={() => rejectWithdraw(row.id)}>
</Button>
</>
)}
</Space>
),
},
];
const withdrawDetailItems =
(detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
const storeAccount = detail?.storeAccount as
| {
name?: string;
phone?: string;
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
}
| undefined;
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
@@ -171,16 +339,21 @@ export default function StoreBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+1 = ×
T+1 沿
</Typography.Text>
{overdueSummary && overdueSummary.overdueCount > 0 ? (
<Typography.Text type="danger">
{overdueSummary.overdueCount} {overdueSummary.pendingCount}
</Typography.Text>
) : null}
</Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="明细笔数" value={summary.count} />
<Statistic title="核销额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
<Statistic title="记录数" value={summary.count} />
<Statistic title="核销额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.payoutAmount ?? summary.totalAmount ?? 0} prefix="¥" precision={2} />
</Space>
</Card>
)}
@@ -189,20 +362,38 @@ export default function StoreBillsPage() {
form={form}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={{
kind: filters.kind || undefined,
status: filters.status || undefined,
storeId: filters.storeId || undefined,
}}
onFinish={(v: {
kind?: string;
storeId?: string;
status?: string;
dateRange?: [Dayjs, Dayjs];
range?: [Dayjs, Dayjs];
}) => {
setFilters({
kind: v.kind || '',
storeId: v.storeId || '',
status: v.status || '',
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
});
setPage(1);
setSelectedKeys([]);
}}
>
<Form.Item name="kind" label="类型">
<Select
allowClear
style={{ width: 140 }}
options={Object.entries(STORE_SETTLEMENT_KIND_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item name="storeId" label="门店">
<Select
allowClear
@@ -210,21 +401,23 @@ export default function StoreBillsPage() {
placeholder="全部门店"
style={{ width: 200 }}
optionFilterProp="label"
options={stores.map((s) => ({
value: s.id,
label: `${s.name}${s.phone}`,
}))}
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
/>
</Form.Item>
<Form.Item name="status" label="打款状态">
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
placeholder="全部"
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
options={Object.entries(STORE_SETTLEMENT_STATUS_LABELS).map(([value, label]) => ({
value,
label:
value === 'PAID'
? '已打款/已结算'
: label,
}))}
/>
</Form.Item>
<Form.Item name="dateRange" label="核销日期">
<Form.Item name="range" label="日期">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
@@ -238,6 +431,7 @@ export default function StoreBillsPage() {
form.resetFields();
setFilters({});
setPage(1);
setSelectedKeys([]);
}}
>
@@ -245,17 +439,35 @@ export default function StoreBillsPage() {
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
T+1 Excel
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedRows.length}
loading={batchLoading}
onClick={() => confirmPay(selectedRows.map((r) => r.id), selectedAmount)}
>
({selectedRows.length})
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
rowKey={(r) => `${r.kind}-${r.id}`}
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
}),
}}
scroll={{ x: 1300 }}
pagination={{
current: page,
@@ -269,28 +481,133 @@ export default function StoreBillsPage() {
}}
/>
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="门店">
{String((detail.store as { name?: string })?.name ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="核销单号">
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
<Descriptions.Item label="结算比例">
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
</Descriptions.Item>
<Descriptions.Item label="状态">
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
</Descriptions.Item>
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
<Descriptions.Item label="实际打款">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
</Descriptions>
<Drawer
title={detailKind === 'WITHDRAW' ? '手动提现明细' : 'T+1 对账单明细'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={detailKind === 'WITHDRAW' ? 640 : 520}
>
{detail && detailKind === 'T1_BILL' && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账单日">
{String(detail.billDate || '').slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="应付">
¥{Number(detail.payoutAmount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{storeSettlementStatusLabel('T1_BILL', String(detail.status))}
</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
columns={[
{
title: '核销单号',
render: (_, r) =>
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
},
{
title: '金额',
dataIndex: 'payoutAmount',
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
},
]}
/>
</>
)}
{detail && detailKind === 'WITHDRAW' && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
<Descriptions.Item label="金额">
¥{Number(detail.amount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{storeSettlementStatusLabel('WITHDRAW', String(detail.status))}
</Descriptions.Item>
<Descriptions.Item label="申请时间">
{detail.appliedAt ? fmtTime(String(detail.appliedAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回理由">
{detail.rejectReason ? String(detail.rejectReason) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{storeAccount ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{storeAccount.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{storeAccount.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{storeAccount.bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={withdrawDetailItems}
columns={[
{
title: '核销单号',
render: (_, r) => {
const payout = r.storePayout as
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
| undefined;
return String(payout?.redeemRecord?.redeemNo || '—');
},
},
{
title: '应付',
render: (_, r) => {
const payout = r.storePayout as { payoutAmount?: number } | undefined;
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
},
},
]}
/>
{String(detail.status) === 'PENDING_REVIEW' ? (
<Space style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => approveWithdraw(String(detail.id))}>
</Button>
<Button danger onClick={() => rejectWithdraw(String(detail.id))}>
</Button>
</Space>
) : null}
</>
)}
</Drawer>
</div>
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react';
import {
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
type CategoryNode = {
id: string;
code: string;
name: string;
sort: number;
parentId: string | null;
status: string;
children?: CategoryNode[];
};
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
function flattenTree(tree: CategoryNode[]): FlatRow[] {
const rows: FlatRow[] = [];
for (const root of tree) {
rows.push({ ...root, level: 1, children: undefined });
for (const child of root.children ?? []) {
rows.push({
...child,
level: 2,
parentName: root.name,
children: undefined,
});
}
}
return rows;
}
export default function StoreCategoriesPage() {
const [tree, setTree] = useState<CategoryNode[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FlatRow | null>(null);
const [form] = Form.useForm();
const rows = useMemo(() => flattenTree(tree), [tree]);
const rootOptions = useMemo(
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
[tree],
);
async function reload() {
setLoading(true);
try {
const data = await request<CategoryNode[]>('/admin/store-categories');
setTree(Array.isArray(data) ? data : []);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, []);
function openCreate(parentId?: string) {
setEditing(null);
form.setFieldsValue({
code: '',
name: '',
sort: 0,
parentId: parentId || undefined,
status: 'ACTIVE',
});
setModalOpen(true);
}
function openEdit(row: FlatRow) {
setEditing(row);
form.setFieldsValue({
code: row.code,
name: row.name,
sort: row.sort,
parentId: row.parentId || undefined,
status: row.status,
});
setModalOpen(true);
}
async function submit() {
const values = await form.validateFields();
const payload = {
code: String(values.code).trim().toUpperCase(),
name: String(values.name).trim(),
sort: Number(values.sort ?? 0),
parentId: values.parentId || null,
status: values.status || 'ACTIVE',
};
if (editing) {
await request(`/admin/store-categories/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('已保存');
} else {
await request('/admin/store-categories', {
method: 'POST',
body: JSON.stringify(payload),
});
message.success('已创建');
}
setModalOpen(false);
void reload();
}
const columns: ColumnsType<FlatRow> = [
{
title: '层级',
dataIndex: 'level',
width: 80,
render: (level) => (level === 1 ? <Tag color="blue"></Tag> : <Tag></Tag>),
},
{
title: '名称',
dataIndex: 'name',
render: (name, row) => (
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
{name}
</span>
),
},
{ title: '编码', dataIndex: 'code', width: 140 },
{ title: '排序', dataIndex: 'sort', width: 80 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button>
{row.level === 1 ? (
<Button type="link" size="small" onClick={() => openCreate(row.id)}></Button>
) : null}
<Popconfirm
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
onConfirm={async () => {
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
message.success('已处理');
void reload();
}}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">/宿/ </Typography.Text>
</Space>
<Space>
<Button
onClick={async () => {
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
message.success('已同步默认分类');
void reload();
}}
>
</Button>
<Button type="primary" onClick={() => openCreate()}></Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
className="admin-table-nowrap"
/>
<Modal
title={editing ? '编辑分类' : '新增分类'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void submit()}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="分类名称" />
</Form.Item>
<Form.Item name="parentId" label="上级分类(空=一级)">
<Select
allowClear
placeholder="不选则为一级分类"
options={rootOptions.filter((o) => o.value !== editing?.id)}
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
/>
</Form.Item>
<Form.Item name="sort" label="排序" initialValue={0}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select
options={[
{ value: 'ACTIVE', label: '启用' },
{ value: 'DISABLED', label: '停用' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,600 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Badge,
Button,
Descriptions,
Drawer,
Image,
Input,
Modal,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type {
StoreInfoChangeFieldDiff,
StoreInfoChangeRequestDto,
StoreInfoChangeStatus,
StorePackageAuditSummaryDto,
StorePackageChangeRequestDto,
StorePackageChangeStatus,
} from '@dukang/shared-types';
import {
STORE_INFO_CHANGE_STATUS_LABELS,
STORE_INFO_CHANGEABLE_FIELD_LABELS,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核',
APPROVED: '已通过',
REJECTED: '已驳回',
};
function fmtFieldValue(field: string, v: unknown): string {
if (v == null || String(v).trim() === '') return '(空)';
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
return String(v);
}
if (field === 'envPhotoUrls' && Array.isArray(v)) {
return `${v.length}`;
}
if (field === 'coverUrl') return '见对比图';
return String(v);
}
function InfoChangeImageDiff({
field,
live,
proposed,
}: {
field: string;
live: unknown;
proposed: unknown;
}) {
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)
: [];
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={72} height={72} 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={72} height={72} 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 [items, setItems] = useState<StoreInfoChangeRequestDto[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
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 [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
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<{ items: StoreInfoChangeRequestDto[]; total: number; page?: number }>(
`/admin/store-info-change-requests?${qs}`,
),
request<{ pendingCount: number }>('/admin/store-info-change-requests/summary'),
]);
setItems(data.items);
setTotal(data.total);
setPage(data.page ?? nextPage);
setPendingCount(summary.pendingCount ?? 0);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload(1, status);
}, [status]);
useEffect(() => {
if (initialRequestId) void openDetail(initialRequestId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function openDetail(id: string) {
setDetailOpen(true);
setDetailLoading(true);
setDetail(null);
try {
const data = await request<StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }>(
`/admin/store-info-change-requests/${id}`,
);
setDetail(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载详情失败');
setDetailOpen(false);
} finally {
setDetailLoading(false);
}
}
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
try {
await request(`/admin/store-info-change-requests/${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 columns: ColumnsType<StoreInfoChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '状态',
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: '提交方',
render: (_, row) =>
row.submitterType === 'PARTNER' ? '合伙人' : row.submitterType === 'SHOP' ? '门店' : '总部',
},
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
{
title: '操作',
render: (_, row) => (
<Space>
<Button type="link" onClick={() => void 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>
),
},
];
return (
<div>
<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]}>
{STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
STORE_INFO_CHANGE_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),
}}
/>
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={680}
open={detailOpen}
onClose={() => setDetailOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null
}
>
{detailLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : detail ? (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : detail.submitterType === 'SHOP' ? '门店' : '总部'} · {fmtTime(detail.createdAt)}
</Typography.Text>
</Space>
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
{detail.diffs && detail.diffs.length ? (
<Descriptions column={1} bordered size="small">
{detail.diffs.map((d) => (
<Descriptions.Item
key={d.field}
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
>
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
) : (
<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}
</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('套餐变更详情');
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 columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
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>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<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={880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
destroyOnClose
>
{activeRequestId ? (
<StorePackageAuditPanel
requestId={activeRequestId}
onAudited={() => {
setDetailOpen(false);
void reload(page, status);
}}
onDetailLoaded={(d) => {
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
}}
/>
) : null}
</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>
);
}
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { Button, Form, Input, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
serviceScore: number;
envScore: number;
createdAt: string;
redeemNo: string;
redeemAmount: number;
store?: { id: string; name: string; cityName?: string };
user?: { userNo: string; phone: string | null; nickname: string | null };
};
export default function StoreRatingsPage() {
const [searchParams] = useSearchParams();
const initialStoreId = searchParams.get('storeId') || '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
...(initialStoreId ? { storeId: initialStoreId } : {}),
});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/store-ratings',
() => {
const qs = new URLSearchParams();
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
return qs;
},
[filters],
);
const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{
title: '门店',
dataIndex: ['store', 'name'],
render: (v, row) => (row.store?.cityName ? `${v}${row.store.cityName}` : v),
},
{
title: '用户',
width: 140,
render: (_, row) => row.user?.phone || row.user?.userNo || '-',
},
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
{ title: '服务分', dataIndex: 'serviceScore', width: 80 },
{ title: '环境分', dataIndex: 'envScore', width: 80 },
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
];
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={filters}
onFinish={(v) => {
setFilters({
storeId: v.storeId || '',
redeemNo: v.redeemNo || '',
});
setPage(1);
}}
>
<Form.Item name="storeId" label="门店ID">
<Input allowClear placeholder="storeId" />
</Form.Item>
<Form.Item name="redeemNo" label="核销号">
<Input allowClear />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
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);
},
}}
/>
</div>
);
}
@@ -0,0 +1,394 @@
import { useEffect, useState } from 'react';
import {
Button,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
withdrawNo: string;
amount: number;
payoutCount: number;
status: StoreWithdrawStatus;
appliedAt: string;
reviewedAt?: string | null;
paidAt?: string | null;
paymentRef?: string | null;
rejectReason?: string | null;
overdue?: boolean;
store?: { id: string; name: string; cityName: string; phone?: string };
};
type StoreOption = { id: string; name: string; phone: string };
const STATUS_COLORS: Record<string, string> = {
PENDING_REVIEW: 'orange',
REJECTED: 'red',
PAID: 'green',
};
export default function StoreWithdrawalsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({ status: 'PENDING_REVIEW' });
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-withdrawals',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [overdueSummary, setOverdueSummary] = useState<{
pendingCount: number;
overdueCount: number;
} | null>(null);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setStores(res.items))
.catch(() => {});
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
}, []);
async function openDetail(id: string) {
const d = await request<Record<string, unknown>>(`/admin/store-withdrawals/${id}`);
setDetail(d);
setDrawerOpen(true);
}
function approve(id: string) {
let paymentRef = '';
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) {
let reason = '';
Modal.confirm({
title: '驳回提现申请?',
content: (
<Input.TextArea
rows={3}
placeholder="请填写驳回理由"
onChange={(e) => {
reason = e.target.value;
}}
/>
),
okText: '确认驳回',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (!reason.trim()) {
message.error('请填写驳回理由');
throw new Error('reason required');
}
await request(`/admin/store-withdrawals/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason.trim() }),
});
message.success('已驳回');
setDrawerOpen(false);
reload();
},
});
}
const columns: ColumnsType<Row> = [
{
title: '提现单号',
dataIndex: 'withdrawNo',
width: 180,
render: (v, row) => (
<Space>
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link>
{row.overdue ? <Tag color="magenta"></Tag> : null}
</Space>
),
},
{
title: '门店',
dataIndex: ['store', 'name'],
width: 160,
render: (_, row) => (
<div>
<div>{row.store?.name || '—'}</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.store?.cityName} {row.store?.phone}
</Typography.Text>
</div>
),
},
{
title: '金额',
dataIndex: 'amount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{ title: '明细笔数', dataIndex: 'payoutCount', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: StoreWithdrawStatus) => (
<Tag color={STATUS_COLORS[v]}>{STORE_WITHDRAW_STATUS_LABELS[v] ?? v}</Tag>
),
},
{
title: '申请时间',
dataIndex: 'appliedAt',
width: 170,
render: (v) => fmtTime(v),
},
{
title: '操作',
key: 'actions',
width: 180,
fixed: 'right',
render: (_, row) => (
<Space>
<Button size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'PENDING_REVIEW' ? (
<>
<Button size="small" type="primary" onClick={() => approve(row.id)}>
</Button>
<Button size="small" danger onClick={() => reject(row.id)}>
</Button>
</>
) : null}
</Space>
),
},
];
const detailItems = (detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
const storeAccount = detail?.storeAccount as
| {
name?: string;
phone?: string;
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
}
| undefined;
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
{overdueSummary ? (
<Typography.Paragraph type="secondary">
{overdueSummary.pendingCount}
{overdueSummary.overdueCount > 0 ? (
<Typography.Text type="danger">
{' '}
· {overdueSummary.overdueCount} FIN-003
</Typography.Text>
) : null}
</Typography.Paragraph>
) : null}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16, gap: 8 }}
initialValues={{ status: 'PENDING_REVIEW' }}
onFinish={(v) => {
const range = v.range as [Dayjs, Dayjs] | undefined;
setFilters({
status: v.status || '',
storeId: v.storeId || '',
dateFrom: range?.[0]?.format('YYYY-MM-DD') || '',
dateTo: range?.[1]?.format('YYYY-MM-DD') || '',
});
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 140 }}
options={[
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'PAID', label: '已结算' },
{ value: 'REJECTED', label: '已驳回' },
]}
/>
</Form.Item>
<Form.Item name="storeId" label="门店">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 220 }}
options={stores.map((s) => ({
value: s.id,
label: `${s.name} (${s.phone})`,
}))}
/>
</Form.Item>
<Form.Item name="range" label="申请日">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
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)}
extra={
detail?.status === 'PENDING_REVIEW' ? (
<Space>
<Button type="primary" onClick={() => approve(String(detail.id))}>
</Button>
<Button danger onClick={() => reject(String(detail.id))}>
</Button>
</Space>
) : null
}
>
{detail ? (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLORS[String(detail.status)]}>
{STORE_WITHDRAW_STATUS_LABELS[detail.status as StoreWithdrawStatus] ??
String(detail.status)}
</Tag>
{detail.overdue ? <Tag color="magenta"></Tag> : null}
</Descriptions.Item>
<Descriptions.Item label="金额">¥{Number(detail.amount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="明细笔数">{String(detail.payoutCount)}</Descriptions.Item>
<Descriptions.Item label="申请时间">{fmtTime(String(detail.appliedAt))}</Descriptions.Item>
{detail.rejectReason ? (
<Descriptions.Item label="驳回理由">{String(detail.rejectReason)}</Descriptions.Item>
) : null}
{detail.paymentRef ? (
<Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item>
) : null}
<Descriptions.Item label="收款户名">
{storeAccount?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="收款账号">
{storeAccount?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">{storeAccount?.bankBranch || '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 24 }}>
</Typography.Title>
<Table
size="small"
rowKey={(r) => String((r as { id?: string }).id)}
pagination={false}
dataSource={detailItems}
columns={[
{
title: '核销单号',
render: (_, r) => {
const payout = (r as { storePayout?: { redeemRecord?: { redeemNo?: string } } })
.storePayout;
return payout?.redeemRecord?.redeemNo || '—';
},
},
{
title: '结算额',
render: (_, r) => {
const payout = (r as { storePayout?: { payoutAmount?: number } }).storePayout;
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
},
},
]}
/>
</>
) : null}
</Drawer>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+227 -328
View File
@@ -1,573 +1,472 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Collapse,
Form,
Input,
InputNumber,
Modal,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
import { request } from '../lib/api';
import { request, type HqProfile } from '../lib/api';
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
const { TextArea } = Input;
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
return (
<div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Mock 50
</Typography.Text>
<Table<MockSmsCodeItem>
size="small"
rowKey="id"
loading={loading}
pagination={false}
scroll={{ y: 240 }}
locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
columns={[
{
title: '时间',
dataIndex: 'createdAt',
width: 168,
render: (v: string) => new Date(v).toLocaleString(),
},
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '场景', dataIndex: 'scene', width: 160 },
{
title: '验证码',
dataIndex: 'code',
width: 88,
render: (code: string) => (
<Typography.Text copyable strong>
{code}
</Typography.Text>
),
},
]}
dataSource={codes}
/>
</div>
);
}
function renderField(
field: SystemConfigFieldMeta,
configuredSecrets: string[],
extra?: ReactNode,
) {
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
if (field.type === 'boolean') {
return (
<div key={field.key}>
<Form.Item
name={field.key}
label={
<Space size={4}>
<span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key}
</Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
</Space>
}
tooltip={field.description}
valuePropName="checked"
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
>
<Switch />
</Form.Item>
{extra}
</div>
);
}
if (field.type === 'image' || field.type === 'imageList') {
return (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={4} wrap>
<span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key}
</Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
</Space>
}
tooltip={field.description}
trigger="onChange"
getValueFromEvent={(v: unknown) => (typeof v === 'string' ? v : '')}
normalize={(v) => (typeof v === 'string' ? v : '')}
>
{field.type === 'image' ? (
<ConfigImageField bizType="footer" />
) : (
<ConfigImageListField bizType="swiper" />
)}
</Form.Item>
);
}
const input =
field.type === 'textarea' ? (
<TextArea rows={3} placeholder={field.placeholder} />
) : field.type === 'number' ? (
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
) : field.secret ? (
<Input.Password
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
autoComplete="new-password"
/>
) : (
<Input placeholder={field.placeholder} />
);
return (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={4} wrap>
<span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key}
</Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
{isConfiguredSecret ? <Tag></Tag> : null}
</Space>
}
tooltip={field.description}
>
{input}
</Form.Item>
);
}
export default function SystemSettingsPage() {
const navigate = useNavigate();
const [form] = Form.useForm<Record<string, string>>();
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [dirty, setDirty] = useState(false);
const dirtyRef = useRef(false);
const bypassLeaveRef = useRef(false);
const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
dirtyRef.current = dirty;
async function load(silent = false) {
if (!silent) setLoading(true);
try {
const data = await request<SystemConfigFormResponse>('/admin/system-config');
if (silent) {
setMeta((prev) =>
prev
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
: data,
);
return;
}
setMeta(data);
form.setFieldsValue(data.values);
setDirty(false);
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
} finally {
if (!silent) setLoading(false);
}
}
useEffect(() => {
void load();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
if (!mockSmsEnabled) return;
const timer = window.setInterval(() => void load(true), 5000);
return () => window.clearInterval(timer);
}, [mockSmsEnabled]);
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (!dirtyRef.current) return;
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, []);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (!dirtyRef.current || bypassLeaveRef.current) return;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.('a');
if (!anchor || !(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target === '_blank' || anchor.hasAttribute('download')) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin) return;
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
e.preventDefault();
e.stopPropagation();
Modal.confirm({
title: '有未保存的更改',
content: '离开前请先保存,否则更改将丢失。',
okText: '仍要离开',
cancelText: '留下',
onOk: () => {
bypassLeaveRef.current = true;
setDirty(false);
navigate(`${url.pathname}${url.search}${url.hash}`);
window.setTimeout(() => {
bypassLeaveRef.current = false;
}, 0);
},
});
};
document.addEventListener('click', onDocClick, true);
return () => document.removeEventListener('click', onDocClick, true);
}, [navigate]);
const collapseItems = useMemo(() => {
if (!meta) return [];
const SHARE_SUBGROUP_LABELS: Record<string, string> = {
global: '全局',
home: '首页',
stores: '门店列表',
storeDetail: '门店详情',
benefit: '权益页',
mine: '我的',
productDetail: '商品详情',
orderDetail: '订单详情',
};
const SHARE_SUBGROUP_ORDER = [
'global',
'home',
'stores',
'storeDetail',
'benefit',
'mine',
'productDetail',
'orderDetail',
];
return meta.groups.map((group) => ({
key: group.key,
label: group.label,
children: (
<div style={{ maxWidth: 720 }}>
{meta.fields
.filter((f) => f.group === group.key)
.map((f) =>
renderField(
f,
meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
) : undefined,
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(
f,
meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
) : undefined,
),
)}
</div>
),
}));
}, [meta, mockSmsEnabled, loading]);
async function onSave() {
const values = await form.validateFields();
const payload: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) {
payload[k] = v === undefined || v === null ? '' : String(v);
}
setSaving(true);
try {
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
'/admin/system-config',
{ method: 'PUT', body: JSON.stringify({ values: payload }) },
);
message.success(`已保存 ${res.updatedKeys.length}`);
if (res.requiresRestartKeys.length) {
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
</div>
);
}
await load();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
return {
key: group.key,
label: group.label,
forceRender: true,
children,
};
});
}, [meta, mockSmsEnabled, loading]);
async function onSave() {
await form.validateFields();
const values = form.getFieldsValue(true);
const payload: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) {
payload[k] = v === undefined || v === null ? '' : String(v);
}
setSaving(true);
try {
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
'/admin/system-config',
{ method: 'PUT', body: JSON.stringify({ values: payload }) },
);
message.success(`已保存 ${res.updatedKeys.length}`);
if (res.requiresRestartKeys.length) {
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
}
setDirty(false);
await load();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function onSyncEnv() {
setSyncing(true);
try {
const res = await request<{ message: string; envFilePath: string }>(
'/admin/system-config/sync-env',
{ method: 'POST' },
);
message.success(res.message || '已同步到 env 文件');
} catch (e) {
message.error(e instanceof Error ? e.message : '同步失败');
} finally {
setSyncing(false);
}
}
async function onImportEnv() {
try {
const res = await request<{ imported: number }>('/admin/system-config/import-env', {
method: 'POST',
});
message.success(`已从当前进程环境导入 ${res.imported}`);
await load();
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败');
}
}
return (
<div>
<div style={{ paddingBottom: 88 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
<code>system_config</code> {' '}
<code>{meta?.envFilePath ?? '.env'}</code> 便
</Typography.Paragraph>
</div>
<Space>
<Button onClick={() => void onImportEnv()}></Button>
<Button loading={syncing} onClick={() => void onSyncEnv()}>
env
</Button>
<Button type="primary" loading={saving} onClick={() => void onSave()}>
</Button>
{profile?.adminRole === 'SUPER_ADMIN' ? (
<>
<Button onClick={() => void onImportEnv()}></Button>
<Button loading={syncing} onClick={() => void onSyncEnv()}>
env
</Button>
</>
) : null}
</Space>
</div>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="生效说明"
description={
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
<li>
<Tag color="green"></Tag> <code>process.env</code>Mock
</li>
<li>
<Tag color="orange"></Tag>/OSS <strong> API </strong>
</li>
<li>OSS </li>
<li></li>
</ul>
}
/>
<Card loading={loading}>
<Form form={form} layout="vertical">
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
<Collapse defaultActiveKey={[]} items={collapseItems} />
</Form>
{meta?.updatedAt ? (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
{new Date(meta.updatedAt).toLocaleString()}
</Typography.Text>
) : null}
</Card>
<div
style={{
position: 'fixed',
right: 32,
bottom: 32,
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 8,
}}
>
{dirty ? <Tag color="orange"></Tag> : null}
<Button
type="primary"
size="large"
loading={saving}
onClick={() => void onSave()}
style={{
minWidth: 120,
boxShadow: '0 6px 16px rgba(0,0,0,0.18)',
}}
>
</Button>
</div>
</div>
);
}
@@ -0,0 +1,687 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Card,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
type PhoneRow = {
id: string;
phone: string;
note: string | null;
createdByHqId: string | null;
createdAt: string;
updatedAt: string;
};
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
type LinkedPayload = {
phone: PhoneRow;
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
};
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
{ value: 'user', label: 'C 端用户' },
{ value: 'store_account', label: '门店账号' },
{ value: 'partner', label: '合伙人' },
{ value: 'store', label: '门店' },
{ value: 'order', label: '订单' },
];
export default function TestWhitelistPage() {
const [mockSms, setMockSms] = useState(false);
const [mockWechat, setMockWechat] = useState(false);
const [mockPay, setMockPay] = useState(false);
const [mockLoading, setMockLoading] = useState(true);
const [mockSaving, setMockSaving] = useState(false);
const [phoneForm] = Form.useForm();
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
const [phonesLoading, setPhonesLoading] = useState(false);
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
const [addOpen, setAddOpen] = useState(false);
const [addForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
const [editForm] = Form.useForm();
const [migrating, setMigrating] = useState(false);
const [accountType, setAccountType] = useState<AccountType>('user');
const [accountPhone, setAccountPhone] = useState('');
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
const [accountsLoading, setAccountsLoading] = useState(false);
const [accountPage, setAccountPage] = useState(1);
const [accountPageSize, setAccountPageSize] = useState(20);
const [linkedOpen, setLinkedOpen] = useState(false);
const [linkedLoading, setLinkedLoading] = useState(false);
const [linked, setLinked] = useState<LinkedPayload | null>(null);
async function loadMockFlags() {
setMockLoading(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
} finally {
setMockLoading(false);
}
}
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
setMockSaving(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
{
method: 'PUT',
body: JSON.stringify(body),
},
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
message.success('已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
await loadMockFlags();
} finally {
setMockSaving(false);
}
}
const loadPhones = useCallback(async () => {
setPhonesLoading(true);
try {
const qs = new URLSearchParams({
page: String(phonePage),
pageSize: String(phonePageSize),
});
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
setPhones(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
} finally {
setPhonesLoading(false);
}
}, [phonePage, phonePageSize, phoneFilters]);
const loadAccounts = useCallback(async () => {
setAccountsLoading(true);
try {
const qs = new URLSearchParams({
type: accountType,
page: String(accountPage),
pageSize: String(accountPageSize),
});
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
const res = await request<Paginated<Record<string, unknown>>>(
`/admin/test-whitelist/accounts?${qs}`,
);
setAccounts(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载测试账号失败');
} finally {
setAccountsLoading(false);
}
}, [accountType, accountPage, accountPageSize, accountPhone]);
useEffect(() => {
void loadMockFlags();
}, []);
useEffect(() => {
void loadPhones();
}, [loadPhones]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
async function onAddPhone() {
const v = await addForm.validateFields();
try {
await request('/admin/test-whitelist/phones', {
method: 'POST',
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
});
message.success('已添加');
setAddOpen(false);
addForm.resetFields();
setPhonePage(1);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败');
}
}
async function onEditPhone() {
if (!editRow) return;
const v = await editForm.validateFields();
try {
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
method: 'PATCH',
body: JSON.stringify({ note: v.note ?? null }),
});
message.success('已更新');
setEditOpen(false);
setEditRow(null);
void loadPhones();
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败');
}
}
async function onDeletePhone(id: string) {
try {
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
message.success('已删除');
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function onMigrate() {
setMigrating(true);
try {
const res = await request<{ importedCandidates: number; added: number }>(
'/admin/test-whitelist/migrate-visibility',
{ method: 'POST' },
);
message.success(
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added}`,
);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败');
} finally {
setMigrating(false);
}
}
async function openLinked(row: PhoneRow) {
setLinkedOpen(true);
setLinkedLoading(true);
setLinked(null);
try {
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
setLinked(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载关联失败');
setLinkedOpen(false);
} finally {
setLinkedLoading(false);
}
}
const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 },
{
title: '备注',
dataIndex: 'note',
ellipsis: true,
render: (v) => v || '—',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: fmtTime,
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openLinked(row)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
</Button>
<Popconfirm
title="确认移出白名单?"
description="将同步清除该手机号关联账号的测试标记"
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void onDeletePhone(row.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
function accountColumns(): ColumnsType<Record<string, unknown>> {
if (accountType === 'user') {
return [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store_account') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'partner') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store') {
return [
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{ title: '状态', dataIndex: 'status', width: 110 },
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => `¥${v}`,
},
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户手机',
width: 120,
render: (_, row) =>
(row.user as { phone?: string | null } | undefined)?.phone || '—',
},
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Mock =
</Typography.Text>
<Space wrap>
<Checkbox
checked={mockSms}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockWechat}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockPay}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
>
</Checkbox>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
{MOCK_KEYS.join(' / ')}
</Typography.Text>
</Card>
<Tabs
items={[
{
key: 'phones',
label: '手机号名单',
children: (
<>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
<Form
form={phoneForm}
layout="inline"
onFinish={(v) => {
setPhoneFilters({ phone: v.phone || undefined });
setPhonePage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊搜索" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Space>
<Button
onClick={() => {
Modal.confirm({
title: '从可见性白名单导入',
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
okText: '开始导入',
cancelText: '取消',
onOk: () => onMigrate(),
});
}}
loading={migrating}
>
</Button>
<Button type="primary" onClick={() => setAddOpen(true)}>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={phonesLoading}
columns={phoneColumns}
dataSource={phones?.items ?? []}
pagination={{
current: phonePage,
pageSize: phonePageSize,
total: phones?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPhonePage(p);
setPhonePageSize(ps);
},
}}
/>
</>
),
},
{
key: 'accounts',
label: '测试账号记录',
children: (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Select
style={{ width: 140 }}
value={accountType}
options={ACCOUNT_TYPE_OPTIONS}
onChange={(v: AccountType) => {
setAccountType(v);
setAccountPage(1);
}}
/>
<Input
allowClear
placeholder="按手机号筛选"
style={{ width: 160 }}
value={accountPhone}
onChange={(e) => setAccountPhone(e.target.value)}
onPressEnter={() => {
setAccountPage(1);
void loadAccounts();
}}
/>
<Button
type="primary"
onClick={() => {
if (accountPage !== 1) setAccountPage(1);
else void loadAccounts();
}}
>
</Button>
</Space>
<Table
rowKey="id"
loading={accountsLoading}
columns={accountColumns()}
dataSource={accounts?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: accountPage,
pageSize: accountPageSize,
total: accounts?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setAccountPage(p);
setAccountPageSize(ps);
},
}}
/>
</>
),
},
]}
/>
<Modal
title="添加白名单手机号"
open={addOpen}
onCancel={() => setAddOpen(false)}
onOk={() => void onAddPhone()}
destroyOnClose
>
<Form form={addForm} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
]}
>
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title={`编辑备注 · ${editRow?.phone ?? ''}`}
open={editOpen}
onCancel={() => {
setEditOpen(false);
setEditRow(null);
}}
onOk={() => void onEditPhone()}
destroyOnClose
>
<Form form={editForm} layout="vertical">
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Drawer
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
open={linkedOpen}
onClose={() => setLinkedOpen(false)}
width={560}
destroyOnClose
>
{linkedLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : linked ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
</Descriptions>
<div>
<Typography.Title level={5}>C {linked.users.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.users}
columns={[
{ title: '编号', dataIndex: 'userNo' },
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.storeAccounts.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.storeAccounts}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.partners.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.partners}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.stores.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.stores}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
</Space>
) : null}
</Drawer>
</div>
);
}
+234 -31
View File
@@ -1,6 +1,27 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
import {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
TICKET_STATUS_LABELS,
TICKET_TYPE_LABELS,
ticketStatusLabel,
type TicketStatusDto,
type TicketTypeDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
@@ -8,14 +29,30 @@ import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
ticketNo: string;
ticketType: string;
ticketType: TicketTypeDto;
status: string;
refType: string;
refId: string;
remark?: string;
extraJson?: { evidenceUrls?: string[] };
createdAt: string;
};
const STATUS_COLOR: Partial<Record<TicketStatusDto, string>> = {
PENDING: 'orange',
OPEN: 'blue',
COLLABORATING: 'cyan',
RESOLVED: 'green',
REJECTED: 'red',
COMPLETED: 'green',
CLOSED: 'default',
};
const STATUS_OPTIONS = (Object.keys(TICKET_STATUS_LABELS) as TicketStatusDto[]).map((value) => ({
value,
label: TICKET_STATUS_LABELS[value],
}));
export default function TicketsPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -28,8 +65,15 @@ export default function TicketsPage() {
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<{
ticketType: TicketTypeDto;
orderNo: string;
remark?: string;
}>();
async function approve(id: string) {
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
@@ -48,57 +92,216 @@ export default function TicketsPage() {
setDrawerOpen(false);
}
async function submitCreate() {
const values = await createForm.validateFields();
setCreating(true);
try {
await request('/admin/tickets', {
method: 'POST',
body: JSON.stringify({
ticketType: values.ticketType,
orderNo: values.orderNo.trim(),
remark: values.remark?.trim() || undefined,
}),
});
message.success('工单已创建');
setCreateOpen(false);
createForm.resetFields();
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
const columns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
{ title: '类型', dataIndex: 'ticketType', width: 100 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '类型',
dataIndex: 'ticketType',
width: 110,
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: string) => (
<Tag color={STATUS_COLOR[s as TicketStatusDto] ?? 'default'}>{ticketStatusLabel(s)}</Tag>
),
},
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}></Button>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</div>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="ticketType" label="类型">
<Select allowClear style={{ width: 120 }} options={[
{ value: 'REFUND', label: '退款' },
{ value: 'RESHIPMENT', label: '补发' },
{ value: 'ALERT', label: '异常' },
]} />
<Select
allowClear
style={{ width: 140 }}
options={[
{ value: 'REFUND', label: '仅退款' },
{ value: 'RESHIPMENT', label: '破损补发' },
{ value: 'DAMAGE_RETURN', label: '破损退货' },
{ value: 'RETURN_REFUND', label: '退货退款' },
{ value: 'PACKAGE_DISPUTE', label: '套餐异议' },
{ value: 'ALERT', label: '异常' },
]}
/>
</Form.Item>
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 140 }} placeholder="全部" options={STATUS_OPTIONS} />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
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)}
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<>
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}></Button>
<Button danger onClick={() => reject(String(detail.id))}></Button>
</>
) : null}>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 900 }}
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)}
extra={
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<Space>
<Button type="primary" onClick={() => approve(String(detail.id))}>
</Button>
<Button danger onClick={() => reject(String(detail.id))}>
</Button>
</Space>
) : null
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
<Descriptions.Item label="类型">
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status as TicketStatusDto] ?? 'default'}>
{ticketStatusLabel(detail.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="关联">
{String(detail.refType)} #{String(detail.refId)}
</Descriptions.Item>
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
<Descriptions.Item label="凭证">
{evidenceUrls.length ? (
<Image.PreviewGroup>
{evidenceUrls.map((url) => (
<Image key={url} src={url} width={72} style={{ marginRight: 8 }} />
))}
</Image.PreviewGroup>
) : (
'—'
)}
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
<Modal
title="创建工单"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={creating}
destroyOnClose
okText="提交"
>
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'REFUND' }}>
<Form.Item
name="ticketType"
label="工单类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select
options={[
{ value: 'REFUND', label: '仅退款' },
{ value: 'RESHIPMENT', label: '破损补发' },
{ value: 'DAMAGE_RETURN', label: '破损退货' },
{ value: 'RETURN_REFUND', label: '退货退款' },
{ value: 'ALERT', label: '异常' },
]}
/>
</Form.Item>
<Form.Item
name="orderNo"
label="订单号"
rules={[{ required: true, message: '请填写订单号' }]}
>
<Input placeholder="关联订单号" allowClear />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="可选" maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+132 -21
View File
@@ -17,9 +17,9 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = {
id: string;
@@ -30,6 +30,21 @@ type UserOrderRow = {
createdAt: string;
};
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
function fmtBenefit(v: number | null | undefined) {
const n = Number(v ?? 0);
if (!Number.isFinite(n) || n <= 0) return '—';
return `¥${n.toFixed(2)}`;
}
type UserBehaviorLog = {
id: string;
eventName: string;
clientApp?: string | null;
createdAt: string;
extraJson?: Record<string, unknown> | null;
};
type UserDetail = AdminUserRow & {
wxUnionId?: string | null;
cityPref?: Record<string, unknown> | null;
@@ -66,11 +81,13 @@ export default function UsersPage() {
const navigate = useNavigate();
const location = useLocation();
const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<UserDetail | null>(null);
const [behaviorLogs, setBehaviorLogs] = useState<UserBehaviorLog[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState('');
@@ -82,6 +99,11 @@ export default function UsersPage() {
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false);
const [batchRiskAck, setBatchRiskAck] = useState(false);
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const load = useCallback(async () => {
setLoading(true);
@@ -97,6 +119,7 @@ export default function UsersPage() {
if (values.status !== undefined && values.status !== '') {
qs.set('status', String(values.status));
}
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
setData(res);
} finally {
@@ -117,8 +140,14 @@ export default function UsersPage() {
}, [location.state, location.pathname, navigate]);
async function openDetail(id: string) {
const res = await request<UserDetail>(`/admin/users/${id}`);
const [res, logs] = await Promise.all([
request<UserDetail>(`/admin/users/${id}`),
request<{ items: UserBehaviorLog[] }>(`/admin/logs/users?userId=${id}&pageSize=50`).catch(
() => ({ items: [] as UserBehaviorLog[] }),
),
]);
setDetail(res);
setBehaviorLogs(logs.items ?? []);
setDrawerOpen(true);
}
@@ -216,13 +245,23 @@ export default function UsersPage() {
];
const columns: ColumnsType<AdminUserRow> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{
title: '用户编号',
dataIndex: 'userNo',
width: 140,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '昵称', dataIndex: 'nickname', width: 100 },
{
title: '手机',
dataIndex: 'phone',
width: 120,
render: (v) => v || '—',
render: (v) => maskPhone(v),
},
{
title: '验手机',
@@ -283,6 +322,39 @@ export default function UsersPage() {
render: (v) => (v ? <Tag color="blue"></Tag> : '—'),
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{
title: '剩余权益',
dataIndex: 'benefitBalance',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0),
render: (v: number | undefined) =>
Number(v ?? 0) > 0 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(v)}
</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '已用权益',
dataIndex: 'benefitUsedAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
render: (v: number | undefined) => (
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
),
},
{
title: '累计权益',
dataIndex: 'benefitTotalAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0),
render: (v: number | undefined) => fmtBenefit(v),
},
{
title: '注册时间',
dataIndex: 'createdAt',
@@ -309,13 +381,15 @@ export default function UsersPage() {
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => void openBatchDeleteModal()}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
{canDeleteUsers ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => void openBatchDeleteModal()}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号">
@@ -339,6 +413,9 @@ export default function UsersPage() {
{ value: 0, label: '停用' },
]} />
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
@@ -352,12 +429,12 @@ export default function UsersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
rowSelection={{
scroll={{ x: 1830 }}
rowSelection={canDeleteUsers ? {
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
} : undefined}
pagination={{
current: page,
pageSize,
@@ -375,9 +452,9 @@ export default function UsersPage() {
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={detail && (
extra={detail && canDeleteUsers ? (
<Button danger onClick={openDeleteModal}></Button>
)}
) : undefined}
>
{detail && (
<>
@@ -385,7 +462,7 @@ export default function UsersPage() {
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
<Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
</Descriptions.Item>
@@ -419,16 +496,50 @@ export default function UsersPage() {
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
<Descriptions.Item label="合并至">
{detail.mergedInto
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
: '—'}
</Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
<Descriptions.Item label="好客权益">
<Space size={16} wrap>
<span>
{' '}
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(detail.benefitBalance)}
</Typography.Text>
</span>
<span> {fmtBenefit(detail.benefitUsedAmount)}</span>
<span> {fmtBenefit(detail.benefitTotalAmount)}</span>
</Space>
</Descriptions.Item>
<Descriptions.Item label="注册时间">
{new Date(detail.createdAt).toLocaleString('zh-CN')}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
线 {behaviorLogs.length}
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
scroll={{ y: 200 }}
dataSource={behaviorLogs}
locale={{ emptyText: '暂无行为日志' }}
columns={[
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v) => fmtTime(v) },
{
title: '分类',
width: 100,
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
},
{ title: '事件', dataIndex: 'eventName' },
{ title: '端', dataIndex: 'clientApp', width: 90 },
]}
/>
<Typography.Title level={5} style={{ marginTop: 16 }}>
{detail.orders?.length ?? 0}
</Typography.Title>
@@ -593,7 +704,7 @@ export default function UsersPage() {
columns={[
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => maskPhone(v) },
{
title: '风险',
width: 200,
@@ -5,7 +5,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { fmtTime, maskPhone } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
@@ -119,7 +119,7 @@ export default function WechatBindingsPage() {
title: '手机号',
dataIndex: 'primaryPhone',
width: 140,
render: (v) => v || '—',
render: (v) => maskPhone(v),
},
{
title: '身份摘要',
@@ -128,7 +128,7 @@ export default function WechatBindingsPage() {
<AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
secondary={r.identities
.map((i) => i.refLabel || i.name || i.phone)
.map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : ''))
.filter(Boolean)
.join(' · ')}
/>
@@ -164,7 +164,7 @@ export default function WechatBindingsPage() {
render: (_, r) => (
<AdminCellLine
primary={r.name || '—'}
secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')}
secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')}
/>
),
},
@@ -0,0 +1,129 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useAdminList } from '../lib/useAdminList';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import type { WecomBotLogDto } from '@dukang/shared-types';
export default function WecomBotLogsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [detail, setDetail] = useState<WecomBotLogDto | null>(null);
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<WecomBotLogDto>(
'/admin/logs/wecom-bots',
() => {
const qs = new URLSearchParams();
if (filters.botId) qs.set('botId', filters.botId);
if (filters.wecomUserId) qs.set('wecomUserId', filters.wecomUserId);
if (filters.action) qs.set('action', filters.action);
if (filters.success) qs.set('success', filters.success);
return qs;
},
[filters],
);
const columns: ColumnsType<WecomBotLogDto> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ title: '机器人', dataIndex: 'botName', width: 120, ellipsis: true, render: (v, r) => v || r.botKey || '—' },
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120, ellipsis: true },
{ title: '动作', dataIndex: 'action', width: 160, ellipsis: true },
{ title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' },
{
title: '结果',
dataIndex: 'success',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>,
},
{ title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' },
{ title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => setDetail(row)}>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="botId" label="机器人 ID">
<Input allowClear placeholder="wecom_bot.id" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="wecomUserId" label="企微 userid">
<Input allowClear placeholder="userid" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="action" label="动作">
<Input allowClear placeholder="order.read" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="success" label="结果">
<Select
allowClear
style={{ width: 100 }}
options={[
{ value: 'true', label: '成功' },
{ value: 'false', label: '失败' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="日志详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="机器人">{detail.botName || detail.botKey || '—'}</Descriptions.Item>
<Descriptions.Item label="企微用户">{detail.wecomUserId}</Descriptions.Item>
<Descriptions.Item label="动作">{detail.action}</Descriptions.Item>
<Descriptions.Item label="权限">{detail.permission || '—'}</Descriptions.Item>
<Descriptions.Item label="输入摘要">{detail.inputSummary || '—'}</Descriptions.Item>
<Descriptions.Item label="结果">{detail.success ? '成功' : '失败'}</Descriptions.Item>
<Descriptions.Item label="错误">{detail.errorMessage || '—'}</Descriptions.Item>
<Descriptions.Item label="耗时">{detail.latencyMs != null ? `${detail.latencyMs} ms` : '—'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+621
View File
@@ -0,0 +1,621 @@
import { useEffect, useState } from 'react';
import {
Avatar,
Button,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WECOM_BOT_PERMISSION_GROUPS,
WECOM_BOT_PERMISSION_LABELS,
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
WECOM_BOT_ROLE_LABELS,
WECOM_BOT_ROLES,
type LlmApiConfigOptionDto,
type KnowledgeBaseOptionDto,
type WecomAibotRuntimeDto,
type WecomBotDto,
type WecomBotPermission,
type WecomBotRole,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type FormValues = {
name: string;
role: WecomBotRole;
botId: string;
secret?: string;
avatarUrl?: string;
welcome?: string;
permissions: WecomBotPermission[];
reviewSuperAdminWecomUserIds?: string;
aiEnabled: boolean;
llmConfigId?: string | null;
knowledgeBaseId?: string | null;
enabled: boolean;
sortOrder: number;
};
function parseUserIdLines(text?: string): string[] {
if (!text?.trim()) return [];
return [...new Set(text.split(/[,\s\n]+/).map((s) => s.trim()).filter(Boolean))];
}
function formatUserIdLines(ids?: string[]): string {
return (ids ?? []).join('\n');
}
function mapDtoToForm(row: WecomBotDto): FormValues {
return {
name: row.name,
role: row.role,
botId: row.botId,
secret: '',
avatarUrl: row.avatarUrl || '',
welcome: row.welcome || '',
permissions: [...row.permissions],
reviewSuperAdminWecomUserIds: formatUserIdLines(row.reviewSuperAdminWecomUserIds),
aiEnabled: row.aiEnabled,
llmConfigId: row.llmConfigId,
knowledgeBaseId: row.knowledgeBaseId,
enabled: row.enabled,
sortOrder: row.sortOrder,
};
}
function samePermissionSet(a: WecomBotPermission[], b: WecomBotPermission[]): boolean {
if (a.length !== b.length) return false;
const setB = new Set(b);
return a.every((p) => setB.has(p));
}
const CREATE_DEFAULTS: FormValues = {
name: '',
role: 'CUSTOMER_SERVICE',
botId: '',
secret: '',
avatarUrl: '',
welcome: '',
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
reviewSuperAdminWecomUserIds: '',
aiEnabled: false,
llmConfigId: null,
knowledgeBaseId: null,
enabled: true,
sortOrder: 0,
};
export default function WecomBotsPage() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<WecomBotDto | null>(null);
const [formInitial, setFormInitial] = useState<FormValues | null>(null);
const [formReady, setFormReady] = useState(false);
const [saving, setSaving] = useState(false);
const [detail, setDetail] = useState<WecomBotDto | null>(null);
const [runtime, setRuntime] = useState<WecomAibotRuntimeDto>();
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
const roleWatch = Form.useWatch('role', form);
const permissionsWatch = Form.useWatch('permissions', form) as WecomBotPermission[] | undefined;
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<WecomBotDto>(
'/admin/wecom-bots',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.role) qs.set('role', filters.role);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
const loadRuntime = () =>
request<WecomAibotRuntimeDto>('/admin/wecom-bots/runtime')
.then(setRuntime)
.catch(() => {});
useEffect(() => {
void loadRuntime();
}, [data]);
useEffect(() => {
void request<{
llmConfigs: LlmApiConfigOptionDto[];
knowledgeBases: KnowledgeBaseOptionDto[];
}>('/admin/wecom-bots/ai-options')
.then((res) => {
setLlmOptions(res.llmConfigs);
setKbOptions(res.knowledgeBases);
})
.catch(() => {
setLlmOptions([]);
setKbOptions([]);
});
}, []);
useEffect(() => {
if (!modalOpen || !formReady || !formInitial) return;
form.resetFields();
form.setFieldsValue(formInitial);
}, [modalOpen, formReady, formInitial, form]);
function closeModal() {
setModalOpen(false);
setFormReady(false);
setFormInitial(null);
setEditing(null);
form.resetFields();
}
function openCreate() {
setEditing(null);
setFormInitial({ ...CREATE_DEFAULTS });
setFormReady(true);
setModalOpen(true);
}
async function openEdit(row: WecomBotDto) {
setEditing(row);
setFormInitial(null);
setFormReady(false);
setModalOpen(true);
try {
const detail = await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`);
setEditing(detail);
setFormInitial(mapDtoToForm(detail));
} catch (e) {
message.error(e instanceof Error ? e.message : '加载机器人详情失败');
setFormInitial(mapDtoToForm(row));
} finally {
setFormReady(true);
}
}
async function submit() {
const values = await form.validateFields();
const permissions = (form.getFieldValue('permissions') ?? values.permissions) as WecomBotPermission[];
if (!permissions?.length) {
message.error('请至少选择一项权限');
return;
}
setSaving(true);
try {
const payload = {
name: values.name.trim(),
role: values.role,
botId: values.botId.trim(),
avatarUrl: values.avatarUrl?.trim() || null,
welcome: values.welcome?.trim() || null,
permissions,
reviewSuperAdminWecomUserIds: parseUserIdLines(values.reviewSuperAdminWecomUserIds),
aiEnabled: values.aiEnabled,
llmConfigId: values.llmConfigId || null,
knowledgeBaseId: values.knowledgeBaseId || null,
enabled: values.enabled,
sortOrder: values.sortOrder,
};
if (editing) {
const saved = await request<WecomBotDto>(`/admin/wecom-bots/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({
...payload,
secret: values.secret?.trim() || undefined,
}),
});
if (!samePermissionSet(permissions, saved.permissions)) {
message.warning('权限与保存结果不一致,已用服务端数据刷新表单');
setFormInitial(mapDtoToForm(saved));
setEditing(saved);
reload();
return;
}
message.success('已更新');
} else {
if (!values.secret?.trim()) {
message.error('请填写 Secret');
return;
}
await request('/admin/wecom-bots', {
method: 'POST',
body: JSON.stringify({
...payload,
secret: values.secret.trim(),
}),
});
message.success('已创建');
}
closeModal();
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function remove(id: string) {
try {
await request(`/admin/wecom-bots/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function reloadConnections() {
try {
const st = await request<WecomAibotRuntimeDto>('/admin/wecom-bots/reload', {
method: 'POST',
body: '{}',
});
message.success('已重载长连接');
setRuntime(st);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '重载失败');
}
}
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
const columns: ColumnsType<WecomBotDto> = [
{
title: '头像',
dataIndex: 'avatarUrl',
width: 64,
render: (url: string | null, row) => (
<Avatar src={url || undefined} shape="square" size={40}>
{row.name.slice(0, 1)}
</Avatar>
),
},
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
},
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
{
title: '权限',
dataIndex: 'permissions',
ellipsis: true,
render: (perms: WecomBotPermission[]) =>
perms.map((p) => (
<Tag key={p} style={{ marginBottom: 2 }}>
{WECOM_BOT_PERMISSION_LABELS[p] || p}
</Tag>
)),
},
{
title: 'AI',
width: 100,
render: (_, row) =>
row.aiEnabled ? (
<Tag color="blue">{row.llmConfigName || '已开'}</Tag>
) : (
<Tag></Tag>
),
},
{
title: '启用',
dataIndex: 'enabled',
width: 70,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '是' : '否'}</Tag>,
},
{
title: '连接',
width: 80,
render: (_, row) => {
const rt = runtimeMap.get(row.id);
if (!runtime?.masterEnabled) return <Tag></Tag>;
if (!row.enabled) return <Tag></Tag>;
return (
<Tag color={rt?.connected ? 'green' : 'orange'}>{rt?.connected ? '已连接' : '未连接'}</Tag>
);
},
},
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作',
width: 180,
fixed: 'right',
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => void openEdit(row)}>
</Button>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`));
}}
>
</Button>
<Popconfirm title="确认删除该机器人?" onConfirm={() => remove(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
BotID / Secret / / /
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
</Typography.Text>
</div>
<Space>
<Button onClick={() => void reloadConnections()}></Button>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="名称" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select
allowClear
style={{ width: 160 }}
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
/>
</Form.Item>
<Form.Item name="enabled" label="启用">
<Select
allowClear
style={{ width: 100 }}
options={[
{ value: 'true', label: '是' },
{ value: 'false', label: '否' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑企微机器人' : '创建企微机器人'}
open={modalOpen}
onCancel={closeModal}
onOk={() => void submit()}
confirmLoading={saving}
width={640}
destroyOnClose
>
{formReady && formInitial ? (
<Form
key={editing ? `edit-${editing.id}` : 'create'}
form={form}
layout="vertical"
preserve={false}
initialValues={formInitial}
>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:客服机器人" maxLength={64} />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
/>
</Form.Item>
<Form.Item>
<Button
size="small"
onClick={() => {
const role = form.getFieldValue('role') as WecomBotRole | undefined;
if (!role) return;
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
}}
>
</Button>
</Form.Item>
<Form.Item name="avatarUrl" label="头像">
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item name="botId" label="BotID" rules={[{ required: true, message: '请填写 BotID' }]}>
<Input placeholder="企业微信后台长连接 BotID" />
</Form.Item>
<Form.Item
name="secret"
label="Secret"
rules={editing ? [] : [{ required: true, message: '请填写 Secret' }]}
extra={editing ? '留空表示不修改' : undefined}
>
<Input.Password placeholder={editing ? '留空不修改' : '长连接专用 Secret'} />
</Form.Item>
<Form.Item
name="permissions"
label="权限(按模块)"
rules={[
{
validator: (_, v: WecomBotPermission[] | undefined) =>
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少选择一项权限')),
},
]}
extra={
<>
{roleWatch
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
: null}
{permissionsWatch?.length ? (
<Typography.Text type="secondary"> · {permissionsWatch.length} </Typography.Text>
) : null}
</>
}
>
<Checkbox.Group style={{ width: '100%' }}>
{WECOM_BOT_PERMISSION_GROUPS.map((group) => (
<div key={group.key} style={{ marginBottom: 12 }}>
<Typography.Text strong>{group.label}</Typography.Text>
<div style={{ marginTop: 4 }}>
{group.permissions.map((p) => (
<Checkbox key={p} value={p} style={{ marginInlineStart: 0, marginRight: 12 }}>
{WECOM_BOT_PERMISSION_LABELS[p]}
</Checkbox>
))}
</div>
</div>
))}
</Checkbox.Group>
</Form.Item>
{(roleWatch === 'TECH_SUPPORT' || permissionsWatch?.includes('support_ticket.review')) && (
<Form.Item
name="reviewSuperAdminWecomUserIds"
label="审批白名单(企微 userid"
extra="填写成员企微 userid(如 woDacESQAAD…),不是姓名/昵称;每行或逗号分隔。发「状态」或在服务端日志 inbound user= 可核对"
>
<Input.TextArea rows={3} placeholder="zhangsan&#10;lisi" />
</Form.Item>
)}
<Form.Item name="welcome" label="欢迎语">
<Input.TextArea rows={3} placeholder="进入会话时的欢迎语,可空" />
</Form.Item>
<Form.Item
name="aiEnabled"
label="启用 AI 问答"
valuePropName="checked"
extra="开启后,未匹配指令的自然语言将调用绑定的语言模型(可挂知识库)"
>
<Switch />
</Form.Item>
<Form.Item name="llmConfigId" label="语言模型">
<Select
allowClear
placeholder="选择已生效的配置"
options={llmOptions.map((o) => ({
value: o.id,
label: `${o.name}${o.modelName}`,
}))}
/>
</Form.Item>
<Form.Item name="knowledgeBaseId" label="知识库">
<Select
allowClear
placeholder="可选,供 AI 检索"
options={kbOptions.map((o) => ({
value: o.id,
label: `${o.name}${o.documentCount} 篇)`,
}))}
/>
</Form.Item>
<Space size="large">
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber style={{ width: 120 }} />
</Form.Item>
</Space>
</Form>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Modal>
<Drawer title="机器人详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="头像">
<Avatar src={detail.avatarUrl || undefined} size={64} shape="square">
{detail.name.slice(0, 1)}
</Avatar>
</Descriptions.Item>
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="角色">
{WECOM_BOT_ROLE_LABELS[detail.role] || detail.role}
</Descriptions.Item>
<Descriptions.Item label="BotID">{detail.botId}</Descriptions.Item>
<Descriptions.Item label="Secret">
{detail.secretConfigured ? '已配置' : '未配置'}
</Descriptions.Item>
<Descriptions.Item label="权限">
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
</Descriptions.Item>
<Descriptions.Item label="审批白名单">
{detail.reviewSuperAdminWecomUserIds?.length
? detail.reviewSuperAdminWecomUserIds.join('、')
: '—'}
</Descriptions.Item>
<Descriptions.Item label="欢迎语">{detail.welcome || '—'}</Descriptions.Item>
<Descriptions.Item label="AI 问答">{detail.aiEnabled ? '开' : '关'}</Descriptions.Item>
<Descriptions.Item label="语言模型">{detail.llmConfigName || '—'}</Descriptions.Item>
<Descriptions.Item label="知识库">{detail.knowledgeBaseName || '—'}</Descriptions.Item>
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
<Descriptions.Item label="创建">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新">{fmtTime(detail.updatedAt)}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
@@ -0,0 +1,611 @@
import { useCallback, useEffect, useState } from 'react';
import {
Avatar,
Button,
Checkbox,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WECOM_PUSH_CONDITION_GROUPS,
WECOM_PUSH_CONDITION_LABELS,
WECOM_TEMPLATE_EVENT_KEYS,
WECOM_TEMPLATE_EVENT_LABELS,
WECOM_TEMPLATE_PLACEHOLDERS,
type WecomMessagePushDto,
type WecomPushCondition,
type WecomPushTemplateDto,
type WecomTemplateEventKey,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type FormValues = {
name: string;
avatarUrl?: string;
webhookUrl: string;
enabled: boolean;
mentionWecomUserId?: string;
pushConditions: WecomPushCondition[];
sortOrder: number;
};
function WecomPushConditionPicker({
value,
onChange,
}: {
value?: WecomPushCondition[];
onChange?: (next: WecomPushCondition[]) => void;
}) {
const selected = new Set(value ?? []);
function toggle(cond: WecomPushCondition, checked: boolean) {
const next = new Set(selected);
if (checked) next.add(cond);
else next.delete(cond);
onChange?.([...next]);
}
return (
<div style={{ width: '100%' }}>
{WECOM_PUSH_CONDITION_GROUPS.map((group) => (
<div key={group.key} style={{ marginBottom: 12 }}>
<Typography.Text strong>{group.label}</Typography.Text>
<div style={{ marginTop: 8 }}>
{group.conditions.map((c) => (
<div key={c}>
<Checkbox
checked={selected.has(c)}
onChange={(e) => toggle(c, e.target.checked)}
>
{WECOM_PUSH_CONDITION_LABELS[c]}
</Checkbox>
</div>
))}
</div>
</div>
))}
</div>
);
}
function PushRoutesTab() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<WecomMessagePushDto | null>(null);
const [saving, setSaving] = useState(false);
const [detail, setDetail] = useState<WecomMessagePushDto | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<WecomMessagePushDto>(
'/admin/wecom-message-pushes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
const conditionsWatch = Form.useWatch('pushConditions', form) as WecomPushCondition[] | undefined;
function openCreate() {
setEditing(null);
form.resetFields();
form.setFieldsValue({
enabled: true,
pushConditions: ['alert.ops'],
sortOrder: 0,
});
setModalOpen(true);
}
function openEdit(row: WecomMessagePushDto) {
setEditing(row);
form.setFieldsValue({
name: row.name,
avatarUrl: row.avatarUrl ?? undefined,
webhookUrl: row.webhookUrl,
enabled: row.enabled,
mentionWecomUserId: row.mentionWecomUserId ?? undefined,
pushConditions: row.pushConditions,
sortOrder: row.sortOrder,
});
setModalOpen(true);
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const body = {
name: values.name,
avatarUrl: values.avatarUrl || null,
webhookUrl: values.webhookUrl,
enabled: values.enabled,
mentionWecomUserId: values.mentionWecomUserId?.trim() || null,
pushConditions: values.pushConditions,
sortOrder: values.sortOrder ?? 0,
};
if (editing) {
await request(`/admin/wecom-message-pushes/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
message.success('已更新');
} else {
await request('/admin/wecom-message-pushes', {
method: 'POST',
body: JSON.stringify(body),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function remove(id: string) {
try {
await request(`/admin/wecom-message-pushes/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function testPush(id: string) {
setTestingId(id);
try {
const res = await request<{ ok: boolean; message: string }>(
`/admin/wecom-message-pushes/${id}/test`,
{ method: 'POST', body: '{}' },
);
message.success(res.message || '已发送测试消息');
} catch (e) {
message.error(e instanceof Error ? e.message : '测试失败');
} finally {
setTestingId(null);
}
}
const columns: ColumnsType<WecomMessagePushDto> = [
{
title: '名称',
dataIndex: 'name',
render: (name: string, row) => (
<Space>
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
<span>{name}</span>
</Space>
),
},
{
title: 'Webhook',
dataIndex: 'webhookUrlMasked',
ellipsis: true,
},
{
title: '推送条件',
dataIndex: 'pushConditions',
render: (conds: WecomPushCondition[]) =>
conds.map((c) => (
<Tag key={c} style={{ marginBottom: 4 }}>
{WECOM_PUSH_CONDITION_LABELS[c]}
</Tag>
)),
},
{
title: '启用',
dataIndex: 'enabled',
width: 72,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{ title: '排序', dataIndex: 'sortOrder', width: 64 },
{
title: '操作',
key: 'actions',
width: 220,
render: (_, row) => (
<Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Button
type="link"
size="small"
loading={testingId === row.id}
onClick={() => void testPush(row.id)}
>
</Button>
<Button
type="link"
size="small"
onClick={() => {
void request<WecomMessagePushDto>(`/admin/wecom-message-pushes/${row.id}`).then(setDetail);
}}
>
</Button>
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
<Typography.Paragraph type="secondary">
Webhook / .env Webhook URL
</Typography.Paragraph>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => setFilters(v as Record<string, string>)}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="搜索" />
</Form.Item>
<Form.Item name="enabled" label="启用">
<Select
allowClear
placeholder="全部"
style={{ width: 100 }}
options={[
{ value: 'true', label: '启用' },
{ value: 'false', label: '停用' },
]}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
}}
>
</Button>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
</Form.Item>
</Form>
<Table<WecomMessagePushDto>
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑消息推送' : '新建消息推送'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void save()}
confirmLoading={saving}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:业务待办通知群" />
</Form.Item>
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item
name="webhookUrl"
label="Webhook URL"
rules={[{ required: true, message: '请填写 Webhook URL' }]}
>
<Input.Password placeholder="企微群机器人 Webhook" />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name="mentionWecomUserId"
label="企微 userid(可选)"
extra="markdown 推送用 <@userid> @ 群成员;任务派发等场景生效"
>
<Input placeholder="如 woDacESQAAD..." allowClear />
</Form.Item>
<Form.Item
name="pushConditions"
label="推送条件"
rules={[
{
validator: (_, v: WecomPushCondition[] | undefined) =>
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少勾选一项推送条件')),
},
]}
>
<WecomPushConditionPicker />
</Form.Item>
{conditionsWatch?.length ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
{conditionsWatch.length}
{conditionsWatch.map((c) => WECOM_PUSH_CONDITION_LABELS[c]).join('、')}
</Typography.Text>
) : null}
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
title="消息推送详情"
open={!!detail}
onCancel={() => setDetail(null)}
footer={null}
width={560}
>
{detail ? (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="Webhook">{detail.webhookUrlMasked}</Descriptions.Item>
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="userid">{detail.mentionWecomUserId || '—'}</Descriptions.Item>
<Descriptions.Item label="推送条件">
{detail.pushConditions.map((c) => WECOM_PUSH_CONDITION_LABELS[c]).join('、')}
</Descriptions.Item>
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
</Descriptions>
) : null}
</Modal>
</>
);
}
function TemplatesTab() {
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
const [loading, setLoading] = useState(false);
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [preview, setPreview] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
setTemplates(list);
setEventKey((prev) => {
const current = list.find((t) => t.eventKey === prev) ?? list[0];
if (current) {
form.setFieldsValue({
title: current.title,
body: current.body,
handleLabel: current.handleLabel,
});
return current.eventKey;
}
return prev;
});
} catch (e) {
message.error(e instanceof Error ? e.message : '加载模板失败');
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
void load();
}, [load]);
function selectEvent(key: WecomTemplateEventKey) {
setEventKey(key);
const row = templates.find((t) => t.eventKey === key);
if (row) {
form.setFieldsValue({
title: row.title,
body: row.body,
handleLabel: row.handleLabel,
});
setPreview('');
}
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}`,
{
method: 'PUT',
body: JSON.stringify(values),
},
);
message.success('模板已保存');
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function reset() {
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}/reset`,
{ method: 'POST', body: '{}' },
);
message.success('已恢复默认文案');
form.setFieldsValue({
title: updated.title,
body: updated.body,
handleLabel: updated.handleLabel,
});
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
setPreview('');
} catch (e) {
message.error(e instanceof Error ? e.message : '恢复失败');
} finally {
setSaving(false);
}
}
async function testSend() {
setTesting(true);
try {
const res = await request<{ ok: boolean; message: string; preview: string }>(
`/admin/wecom-push-templates/${eventKey}/test`,
{ method: 'POST', body: '{}' },
);
setPreview(res.preview || '');
message.success(res.message || '已发送');
} catch (e) {
message.error(e instanceof Error ? e.message : '测试失败');
} finally {
setTesting(false);
}
}
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
return (
<>
<Typography.Paragraph type="secondary">
使 {'{{orderNo}}'} {'{{handleUrl}}'}
Webhook
</Typography.Paragraph>
<Space align="start" style={{ width: '100%' }} size={24} wrap>
<div style={{ minWidth: 200 }}>
<Typography.Text strong></Typography.Text>
<div style={{ marginTop: 8 }}>
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
<div key={k} style={{ marginBottom: 4 }}>
<Button
type={k === eventKey ? 'primary' : 'text'}
size="small"
onClick={() => selectEvent(k)}
block
style={{ textAlign: 'left' }}
>
{WECOM_TEMPLATE_EVENT_LABELS[k]}
</Button>
</div>
))}
</div>
</div>
<div style={{ flex: 1, minWidth: 360 }}>
<Form form={form} layout="vertical" disabled={loading}>
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="body"
label="正文(企微 markdown"
rules={[{ required: true }]}
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
>
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
<Input placeholder="去处理" />
</Form.Item>
<Space wrap>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
<Button loading={saving}></Button>
</Popconfirm>
<Button loading={testing} onClick={() => void testSend()}>
</Button>
</Space>
</Form>
{preview ? (
<div style={{ marginTop: 16 }}>
<Typography.Text strong></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
whiteSpace: 'pre-wrap',
borderRadius: 8,
}}
>
{preview}
</pre>
</div>
) : null}
</div>
</Space>
</>
);
}
export default function WecomMessagePushesPage() {
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Tabs
items={[
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
]}
/>
</div>
);
}
+333 -57
View File
@@ -1,9 +1,13 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Statistic,
@@ -14,82 +18,213 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
import {
WINERY_SETTLEMENT_RATE,
type SystemConfigFormResponse,
} from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { request, type HqProfile } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type Row = {
orderId: string;
orderNo: string;
deliveryType: string;
cityName?: string;
receiverCity?: string;
payAmount: number;
id: string;
billNo: string;
billDate: string;
orderCount: number;
orderAmount: number;
wineryRate: number;
wineryAmount: number;
paidAt: string;
productName?: string;
quantity?: number;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
deliveryType: string;
payAmount: number;
wineryAmount: number;
paidAt: string;
};
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
NO_PAYMENT_NEEDED: '无需打款',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
NO_PAYMENT_NEEDED: 'default',
};
function isZeroPayable(amount: number | string | null | undefined) {
return Number(amount ?? 0) === 0;
}
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
function displayWineryStatus(status: string, wineryAmount: number | string) {
if (isZeroPayable(wineryAmount)) {
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
}
return {
key: status,
label: STATUS_LABELS[status] || status,
color: STATUS_COLORS[status] || 'default',
};
}
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
}
const DELIVERY_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
};
const WINERY_BANK_KEYS = [
'WINERY_BANK_ACCOUNT_NAME',
'WINERY_BANK_NAME',
'WINERY_BANK_BRANCH',
'WINERY_BANK_ACCOUNT_NO',
] as const;
export default function WineryBillsPage() {
const [form] = Form.useForm();
const [bankForm] = Form.useForm<Record<string, string>>();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/winery-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs;
},
[filters],
);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [bankOpen, setBankOpen] = useState(false);
const [bankLoading, setBankLoading] = useState(false);
const [bankSaving, setBankSaving] = useState(false);
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const canEditWineryBank =
profile?.adminRole === 'SUPER_ADMIN' ||
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('/admin/winery-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已确认打款');
setSelectedKeys([]);
reload();
},
});
}
async function openDetail(id: string) {
const d = await request<Row & { items?: BillItem[] }>(`/admin/winery-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
async function openBankModal() {
setBankOpen(true);
setBankLoading(true);
try {
const cfg = await request<SystemConfigFormResponse>('/admin/system-config');
const values: Record<string, string> = {};
for (const key of WINERY_BANK_KEYS) {
values[key] = cfg.values[key] ?? '';
}
bankForm.setFieldsValue(values);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
setBankOpen(false);
} finally {
setBankLoading(false);
}
}
async function saveBank() {
const values = await bankForm.validateFields();
setBankSaving(true);
try {
await request('/admin/system-config', {
method: 'PUT',
body: JSON.stringify({ values }),
});
message.success('酒厂银行账户已保存');
setBankOpen(false);
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setBankSaving(false);
}
}
const summary = data?.summary;
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
const columns: ColumnsType<Row> = [
{ title: '单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
{ title: '单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{
title: '配送类型',
dataIndex: 'deliveryType',
width: 90,
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
},
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
{ title: '数量', dataIndex: 'quantity', width: 70 },
{
title: '酒单实付',
dataIndex: 'payAmount',
title: '账单日',
dataIndex: 'billDate',
width: 110,
render: (v) => String(v || '').slice(0, 10),
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{
title: '酒单实付合计',
dataIndex: 'orderAmount',
width: 120,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
@@ -99,36 +234,72 @@ export default function WineryBillsPage() {
render: (v) => `${Math.round(Number(v) * 100)}%`,
},
{
title: '酒厂应付',
title: '应付',
dataIndex: 'wineryAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 170,
render: (v) => (v ? fmtTime(v) : '—'),
title: '状态',
dataIndex: 'status',
width: 100,
render: (s, row) => {
const d = displayWineryStatus(s, row.wineryAmount);
return <Tag color={d.color}>{d.label}</Tag>;
},
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{canConfirmWineryPay(row) && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
</Button>
)}
</Space>
),
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
T+30 / = × {ratePct}%
</Typography.Text>
</Space>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
gap: 16,
}}
>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
T+3 8:00 3 / × {ratePct}%绿 0
</Typography.Text>
</Space>
{canEditWineryBank ? (
<Button type="default" onClick={() => void openBankModal()}>
</Button>
) : null}
</div>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="单数" value={summary.count} />
<Statistic title="单数" value={summary.count} />
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
</Space>
</Card>
)}
@@ -137,27 +308,29 @@ export default function WineryBillsPage() {
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => {
setFilters({
status: v.status || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
deliveryType: v.deliveryType || '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
});
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 130 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="deliveryType" label="配送类型">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
]}
/>
<Form.Item name="range" label="账单日">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
@@ -180,15 +353,29 @@ export default function WineryBillsPage() {
Excel
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
>
({selectedKeys.length})
</Button>
</Form.Item>
</Form>
<Table
rowKey="orderId"
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
}}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
@@ -200,6 +387,95 @@ export default function WineryBillsPage() {
},
}}
/>
<Drawer title="酒厂对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={640}>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
{
title: '配送',
dataIndex: 'deliveryType',
width: 70,
render: (v) => DELIVERY_LABELS[v] || v,
},
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '应付',
dataIndex: 'wineryAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 150,
render: (v) => (v ? fmtTime(v) : '—'),
},
]}
/>
</>
)}
</Drawer>
<Modal
title="酒厂银行账户信息配置"
open={bankOpen}
onCancel={() => setBankOpen(false)}
onOk={() => void saveBank()}
confirmLoading={bankSaving}
destroyOnClose
okText="保存"
>
<Form form={bankForm} layout="vertical" disabled={bankLoading}>
<Form.Item
name="WINERY_BANK_ACCOUNT_NAME"
label="户名"
rules={[{ required: true, message: '请填写户名' }]}
>
<Input placeholder="收款账户户名" />
</Form.Item>
<Form.Item
name="WINERY_BANK_NAME"
label="开户银行"
rules={[{ required: true, message: '请填写开户银行' }]}
>
<Input placeholder="如:中国工商银行" />
</Form.Item>
<Form.Item name="WINERY_BANK_BRANCH" label="开户支行">
<Input placeholder="可选" />
</Form.Item>
<Form.Item
name="WINERY_BANK_ACCOUNT_NO"
label="银行账号"
rules={[{ required: true, message: '请填写银行账号' }]}
>
<Input placeholder="银行卡号" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
Tabs, Tag, Typography, message,
@@ -15,6 +16,8 @@ type XfxConfig = {
hasApiKey: boolean;
signType: string;
ready: boolean;
source?: string;
hint?: string;
};
type ApiResult = {
@@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
HQ APIcmd 100101~100301
{' '}<code>XIAOFEIXIA_MCH_ID</code><code>XIAOFEIXIA_API_KEY</code>
HQ APIcmd 100101~100301
<Link to="/fulfillment-providers"></Link>
使
</Typography.Paragraph>
{config && (
@@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() {
<Descriptions.Item label="状态">
{config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>}
</Descriptions.Item>
<Descriptions.Item label="来源" span={3}>
{config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'}
{config.hint ? ` · ${config.hint}` : ''}
</Descriptions.Item>
</Descriptions>
{!config.ready && (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API"
message="请先在仓配管理中注册小飞侠并填写 API 地址、商户号与 API Key"
/>
)}
</Card>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, type CSSProperties } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Button,
@@ -24,6 +24,16 @@ import {
import { request } from '../../lib/api';
import { fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
const descLabelStyle: CSSProperties = {
whiteSpace: 'nowrap',
width: 108,
};
const descContentStyle: CSSProperties = {
wordBreak: 'break-all',
};
async function downloadQrcode(url: string, filename: string) {
try {
@@ -72,46 +82,25 @@ export default function PromoCodeDetailPage() {
return (
<>
<Row gutter={[16, 16]}>
<Col xs={24} lg={8}>
<Card title="推广二维码" size="small">
{detail.qrcodeUrl ? (
<div style={{ textAlign: 'center' }}>
<img
src={detail.qrcodeUrl}
alt="推广二维码"
style={{ width: 200, height: 200, marginBottom: 12 }}
/>
<Space direction="vertical" style={{ width: '100%' }}>
<Button
block
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
>
</Button>
</Space>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
</Col>
<Col xs={24} lg={16}>
<Row gutter={[16, 16]} align="top">
<Col flex="1 1 480px" style={{ minWidth: 0 }}>
<Card
title="基础信息"
size="small"
styles={{ body: { paddingTop: 12 } }}
extra={(
<Space>
<Button size="small" onClick={() => {
editForm.setFieldsValue({
name: detail.name,
scene: detail.scene,
remark: detail.remark,
ownerUserId: detail.ownerUser?.id,
});
setEditOpen(true);
}}
<Button
size="small"
onClick={() => {
editForm.setFieldsValue({
name: detail.name,
scene: detail.scene,
remark: detail.remark,
ownerUserId: detail.ownerUser?.id,
});
setEditOpen(true);
}}
>
</Button>
@@ -135,7 +124,18 @@ export default function PromoCodeDetailPage() {
</Space>
)}
>
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
<Descriptions
column={2}
bordered
size="small"
layout="horizontal"
labelStyle={descLabelStyle}
contentStyle={descContentStyle}
styles={{
label: descLabelStyle,
content: descContentStyle,
}}
>
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
<Descriptions.Item label="场景">
@@ -144,34 +144,89 @@ export default function PromoCodeDetailPage() {
<Descriptions.Item label="状态">
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="二维码 ID" span={2}>
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
<Descriptions.Item label="活动 ID">
<Typography.Text copyable={{ text: String(detail.id) }} style={{ whiteSpace: 'nowrap' }}>
{detail.id}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="落地链接" span={2}>
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
<Descriptions.Item label="二维码 ID">
<Typography.Text copyable={{ text: detail.qrcodeId }} style={{ whiteSpace: 'nowrap' }}>
{detail.qrcodeId}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="OSS 地址" span={2}>
<Descriptions.Item label="小程序码 OSS" span={2}>
{detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis style={{ maxWidth: '100%' }}>
{detail.qrcodeUrl}
</Typography.Text>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="渠道负责人">
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
<span style={{ whiteSpace: 'nowrap' }}>
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
</span>
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
<Descriptions.Item label="创建时间">
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.createdAt)}</span>
</Descriptions.Item>
<Descriptions.Item label="更新时间">
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.updatedAt)}</span>
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col flex="0 0 220px">
<Card title="小程序码" size="small" styles={{ body: { textAlign: 'center', padding: 12 } }}>
{detail.qrcodeUrl ? (
<>
<img
src={detail.qrcodeUrl}
alt="推广小程序码"
style={{ width: 168, height: 168, display: 'block', margin: '0 auto 8px' }}
/>
<Typography.Paragraph
type="secondary"
style={{ marginBottom: 8, fontSize: 12, whiteSpace: 'nowrap' }}
>
scene={detail.id}
</Typography.Paragraph>
<Button
block
size="small"
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-wxacode.png`)}
>
</Button>
</>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} />
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="归因用户数"
value={stats?.attributionCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="扫码注册用户数"
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
@@ -187,13 +242,10 @@ export default function PromoCodeDetailPage() {
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
</Card>
</Col>
</Row>
<PromoCodeMetricsPanel promoId={detail.id} />
<Modal
title="编辑推广码"
open={editOpen}
@@ -0,0 +1,258 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Card,
DatePicker,
Radio,
Select,
Space,
Table,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
import {
PROMO_METRIC_EVENT_LABELS,
type PromoMetricEventItem,
type PromoMetricEventType,
type PromoMetricTimelineDto,
} from '@dukang/shared-types';
import { request, type Paginated } from '../../lib/api';
import { fmtTime } from '../../lib/constants';
type Props = {
promoId: string;
};
const SERIES = [
{ key: 'scan', name: '扫码进入', color: '#1677ff' },
{ key: 'attribution', name: '归因用户', color: '#52c41a' },
{ key: 'register', name: '扫码注册', color: '#faad14' },
{ key: 'order', name: '订单', color: '#eb2f96' },
] as const;
function buildTimelineQs(promoId: string, range: [Dayjs, Dayjs], granularity: 'day' | 'hour') {
const qs = new URLSearchParams();
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
qs.set('granularity', granularity);
return `/admin/promo-codes/${promoId}/metrics/timeline?${qs.toString()}`;
}
function buildEventsQs(
promoId: string,
page: number,
pageSize: number,
range: [Dayjs, Dayjs],
eventType?: PromoMetricEventType,
) {
const qs = new URLSearchParams();
qs.set('page', String(page));
qs.set('pageSize', String(pageSize));
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
if (eventType) qs.set('eventType', eventType);
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
}
function formatRefId(row: PromoMetricEventItem): string {
if (row.orderId) return `订单 ${row.orderId}`;
if (row.userId) return `用户 ${row.userId}`;
if (row.sessionId) return `会话 ${row.sessionId}`;
return '—';
}
function formatLocation(row: PromoMetricEventItem): string {
const parts = [row.ipProvince, row.ipCity].filter(Boolean);
if (parts.length) return parts.join(' ');
return '—';
}
export default function PromoCodeMetricsPanel({ promoId }: Props) {
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
const [timelineLoading, setTimelineLoading] = useState(false);
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
const [eventsPage, setEventsPage] = useState(1);
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
const [eventsLoading, setEventsLoading] = useState(false);
const loadTimeline = useCallback(() => {
setTimelineLoading(true);
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
.then(setTimeline)
.catch((e) => {
message.error(e instanceof Error ? e.message : '加载趋势失败');
setTimeline(null);
})
.finally(() => setTimelineLoading(false));
}, [promoId, range, granularity]);
const loadEvents = useCallback(() => {
setEventsLoading(true);
return request<Paginated<PromoMetricEventItem>>(
buildEventsQs(promoId, eventsPage, 20, range, eventType),
)
.then(setEvents)
.catch((e) => {
message.error(e instanceof Error ? e.message : '加载事件失败');
setEvents(null);
})
.finally(() => setEventsLoading(false));
}, [promoId, eventsPage, range, eventType]);
useEffect(() => {
void loadTimeline();
}, [loadTimeline]);
useEffect(() => {
void loadEvents();
}, [loadEvents]);
const chartOption = useMemo<EChartsOption>(() => {
const buckets = timeline?.buckets ?? [];
const xData = buckets.map((b) => b.key);
return {
tooltip: { trigger: 'axis' },
legend: { data: SERIES.map((s) => s.name), bottom: 0 },
grid: { left: 48, right: 24, top: 24, bottom: 48 },
xAxis: {
type: 'category',
data: xData,
axisLabel: {
rotate: granularity === 'hour' ? 45 : 0,
formatter: (v: string) => (granularity === 'hour' ? v.slice(5, 16) : v.slice(5)),
},
},
yAxis: { type: 'value', minInterval: 1 },
series: SERIES.map((s) => ({
name: s.name,
type: 'line',
smooth: true,
data: buckets.map((b) => b[s.key]),
itemStyle: { color: s.color },
markPoint: {
symbol: 'pin',
symbolSize: 42,
data: (() => {
const peak = timeline?.peak[s.key];
if (!peak || peak.count <= 0) return [];
return [{ name: '高峰', coord: [peak.key, peak.count], value: peak.count }];
})(),
},
})),
};
}, [timeline, granularity]);
const columns: ColumnsType<PromoMetricEventItem> = [
{
title: '时间',
dataIndex: 'createdAt',
width: 168,
render: (v: string) => fmtTime(v),
},
{
title: '类型',
dataIndex: 'eventType',
width: 108,
render: (v: PromoMetricEventType) => PROMO_METRIC_EVENT_LABELS[v],
},
{
title: 'ID',
key: 'ref',
render: (_, row) => formatRefId(row),
},
{
title: 'IP',
dataIndex: 'clientIp',
width: 128,
render: (v: string | null) => v ?? '—',
},
{
title: '地点',
key: 'location',
width: 120,
render: (_, row) => formatLocation(row),
},
];
return (
<Card
title="数据趋势"
style={{ marginTop: 16 }}
extra={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
线
</Typography.Text>
}
>
<Space wrap style={{ marginBottom: 16 }}>
<DatePicker.RangePicker
value={range}
onChange={(v) => {
if (v?.[0] && v[1]) {
setRange([v[0], v[1]]);
setEventsPage(1);
}
}}
allowClear={false}
/>
<Radio.Group
value={granularity}
onChange={(e) => setGranularity(e.target.value as 'day' | 'hour')}
optionType="button"
buttonStyle="solid"
options={[
{ label: '按日', value: 'day' },
{ label: '按时', value: 'hour' },
]}
/>
</Space>
<ReactECharts
option={chartOption}
style={{ height: 360 }}
showLoading={timelineLoading}
notMerge
/>
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
</Typography.Title>
<Space wrap style={{ marginBottom: 12 }}>
<Select
allowClear
placeholder="全部类型"
style={{ width: 160 }}
value={eventType}
onChange={(v) => {
setEventType(v);
setEventsPage(1);
}}
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Space>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={events?.items ?? []}
loading={eventsLoading}
pagination={{
current: eventsPage,
pageSize: events?.pageSize ?? 20,
total: events?.total ?? 0,
showSizeChanger: false,
onChange: (p) => setEventsPage(p),
}}
/>
</Card>
);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.mp3' {
const src: string;
export default src;
}
+4 -1
View File
@@ -11,7 +11,10 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
"strict": true,
"paths": {
"@dukang/domain": ["../../packages/domain/src/index.ts"]
}
},
"include": ["src"]
}
+10 -1
View File
@@ -1,10 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
},
},
server: {
host: true,
port: 5175,
proxy: { '/api': 'http://localhost:3000' },
proxy: { '/api': apiTarget },
},
});
+5 -1
View File
@@ -9,15 +9,19 @@
"lint": "echo ok"
},
"dependencies": {
"@dukang/shared-ui": "workspace:*",
"@dukang/client-logging": "workspace:*",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
"element-china-area-data": "^6.1.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -0,0 +1 @@
ayPJ4CQqbUcec3jX
+3
View File
@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom';
import AuthGate from './components/AuthGate';
import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import PartnerAppRoutes from './PartnerAppRoutes';
export default function App() {
@@ -8,6 +9,8 @@ export default function App() {
<AuthGate>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route path="/*" element={<PartnerAppRoutes />} />
</Routes>
</AuthGate>
+31 -9
View File
@@ -1,11 +1,12 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { usePartnerSession } from './contexts/PartnerSessionContext';
import { isSubAccount } from './lib/partnerAccess';
import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
import TabLayout from './layouts/TabLayout';
import SubAccountLayout from './layouts/SubAccountLayout';
import HomePage from './pages/HomePage';
import StoreListPage from './pages/StoreListPage';
import StoreDetailPage from './pages/StoreDetailPage';
import StorePackagesPage from './pages/StorePackagesPage';
import StoreCreatePage from './pages/StoreCreatePage';
import OrderListPage from './pages/OrderListPage';
import OrderDetailPage from './pages/OrderDetailPage';
@@ -13,9 +14,11 @@ import CenterPage from './pages/CenterPage';
import PartnerMePage from './pages/PartnerMePage';
import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage';
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
import ProxyOrderListPage from './pages/ProxyOrderListPage';
import ProxyOrderDetailPage from './pages/ProxyOrderDetailPage';
import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage';
import LeaderboardPage from './pages/LeaderboardPage';
@@ -26,19 +29,22 @@ function PrimaryRoutes() {
<Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/stores" element={<StoreListPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/center" element={<CenterPage />} />
</Route>
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
<Route path="/center/bills" element={<BillsPage />} />
<Route path="/center/settlement" element={<SettlementPage />} />
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/center/proxy-orders" element={<ProxyOrderListPage />} />
<Route path="/center/proxy-orders/:id" element={<ProxyOrderDetailPage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/reshipments" element={<ReshipPage />} />
<Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -46,15 +52,31 @@ function PrimaryRoutes() {
}
function SubAccountRoutes() {
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
return (
<Routes>
<Route element={<SubAccountLayout />}>
<Route path="/stores" element={<StoreListPage />} />
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route element={<SubAccountLayout navKind={navKind} />}>
<Route path="/" element={<HomePage />} />
{isWarehouse ? (
<Route path="/orders" element={<OrderListPage tabRoot />} />
) : (
<Route path="/stores" element={<StoreListPage />} />
)}
<Route path="/me" element={<PartnerMePage />} />
</Route>
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} />
{!isWarehouse && (
<>
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
</>
)}
<Route path="/leaderboard" element={<LeaderboardPage />} />
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

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