311 Commits

Author SHA1 Message Date
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
600 changed files with 64499 additions and 11984 deletions
+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
@@ -23,7 +23,7 @@
| Guard | 用途 |
|-------|------|
| JwtAuthGuard | 需登录 |
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) |
| PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
| OptionalJwtAuthGuard | 可选登录(bootstrap |
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`
+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
+17 -2
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 表。
@@ -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,19 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
## 环境与发版
| 环境 | 分支 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev`
- 生产发布 → `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
+3
View File
@@ -11,8 +11,11 @@
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@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",
+41
View File
@@ -5,7 +5,9 @@ import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage';
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 +20,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 +33,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 +46,14 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
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 />;
@@ -64,9 +80,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 +102,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,17 +110,29 @@ 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 />} />
@@ -0,0 +1,253 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { request } from '../lib/api';
import OssUpload from './OssUpload';
type PackageRow = StorePackageItemDto;
function emptyRow(index = 0): PackageRow {
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', imageUrl: '', sortOrder: index };
}
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
setLoading(true);
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
.then((data) => {
setItems(
data.live?.length
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
: [emptyRow()],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false));
}, [storeId]);
function updateAt(index: number, patch: Partial<PackageRow>) {
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() {
const filled = items
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
imageUrl: item.imageUrl?.trim() || null,
sortOrder: index,
}))
.filter((item) => item.name || item.dishes || item.price);
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
return;
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
return;
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
}
}
setSaving(true);
try {
await request(`/admin/stores/${storeId}/packages`, {
method: 'PUT',
body: JSON.stringify({
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
message.success('套餐已保存并生效');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
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="该门店暂无套餐,可添加或保存为空。" />
<Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
</Form>
);
}
return ( <Form layout="vertical" requiredMark={false}>
<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
placeholder="节假日除外"
value={item.usableTime || ''}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</Form.Item>
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
<OssUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
value={item.imageUrl || undefined}
onChange={(url) => updateAt(index, { imageUrl: url })}
/>
</Form.Item>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}> <Input
placeholder="不可叠加"
value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</Form.Item>
</>
) : null}
</div>
);
})}
{items.length < STORE_PACKAGE_MAX_COUNT ? (
<Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button>
) : null}
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
C
</Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
</Form>
);
}
@@ -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>
);
}
@@ -15,8 +15,6 @@ type OssUploadProps = {
placeholder?: string;
};
const DEFAULT_MAX_MB = 10;
export default function OssUpload({
value,
onChange,
@@ -24,7 +22,6 @@ export default function OssUpload({
bizType,
mediaType = 'IMAGE',
accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
@@ -34,12 +31,6 @@ export default function OssUpload({
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 });
@@ -0,0 +1,424 @@
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 [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 allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
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,
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, quantity, deliveryMode, region?.city, region?.district, step]);
useEffect(() => () => stopPoll(), []);
function resetForm() {
stopPoll();
setPhone('');
setReceiverName('');
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
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,
};
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' }),
});
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={setProductId}
options={(options?.products ?? []).map((p) => ({
value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
}))}
/>
</Form.Item>
<Form.Item label="数量" 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,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>
);
}
+176 -8
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 type { MenuProps } from 'antd';
import {
RobotOutlined,
DashboardOutlined,
UserOutlined,
ShoppingOutlined,
@@ -17,12 +18,16 @@ 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';
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 +43,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 +75,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
],
},
{
@@ -66,6 +86,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 +110,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,6 +141,8 @@ 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: '权限分配' },
@@ -108,6 +150,99 @@ const MENU_ITEMS: MenuProps['items'] = [
{ 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',
'/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'];
}
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
@@ -135,26 +270,51 @@ export default function AdminLayout() {
? '/promo-codes'
: location.pathname;
const menuItems = useMemo(() => {
if (!profile) return MENU_ITEMS;
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
}, [profile]);
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 +322,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,
+81 -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;
@@ -116,15 +169,42 @@ export type AdminUserRow = {
orderCount: number;
};
export type AdminOrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
export type AdminOrderRow = {
id: string;
orderNo: string;
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;
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;
+33 -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,18 @@ export const LEDGER_TYPE_LABELS: Record<string, string> = {
export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—';
}
/** 手机号脱敏展示:138****5678;空值返回 — */
export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim();
if (!raw) return '—';
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(
+61 -2
View File
@@ -6,10 +6,20 @@ 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;
openTime?: string;
closeTime?: string;
openTime2?: string;
closeTime2?: string;
avgPrice?: number | null;
coverUrl?: string;
envPhotoUrls?: string[];
contractUrl?: string;
@@ -17,24 +27,73 @@ export type StoreCreateForm = {
bankAccountNo: string;
bankBranch: string;
settlementRate?: 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'
>,
): 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位手机号';
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>
);
}
+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>
);
}
+134 -73
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;
@@ -98,13 +99,25 @@ 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();
@@ -135,6 +148,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 +159,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 +189,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,18 +247,29 @@ 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: '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: 100, ellipsis: true },
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
@@ -267,7 +301,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 +342,7 @@ export default function CityPartnersPage() {
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05);
setCreateCityCode(undefined);
setCreateOpen(true);
}}
>
@@ -327,12 +371,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 +381,12 @@ 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>
<Space>
<Button type="primary" htmlType="submit">
@@ -409,7 +453,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 +473,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 +485,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 +495,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 +534,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 +554,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 +593,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 +602,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}>
@@ -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()}>
+424
View File
@@ -0,0 +1,424 @@
import { useState } from 'react';
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_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
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;
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;
titleName: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
remark?: string;
};
export default function InvoicesPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/invoices',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
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);
async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`));
setDrawerOpen(true);
}
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,
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: 120,
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
},
{ 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',
});
setCreateOpen(true);
}}
>
</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={[
{ value: 'PENDING', label: '待开票' },
{ value: 'ISSUED', label: '已开票' },
{ value: 'REJECTED', label: '已驳回' },
]}
/>
</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.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="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">
+197 -16
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useMemo, useRef, 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';
@@ -31,6 +31,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 +54,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[];
@@ -57,13 +67,26 @@ type ProductFormValues = {
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
type UserPickRow = {
id: string;
phone?: string | null;
nickname?: string | null;
userNo?: string;
};
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: ((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
@@ -89,8 +112,11 @@ function buildProductPayload(v: ProductFormValues) {
features: features.length ? features : undefined,
};
const visibilityPhones = (v.visibilityPhones ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean);
return {
skuCode: v.skuCode,
barcode69: v.barcode69,
name: v.name,
subtitle: v.subtitle,
@@ -100,6 +126,12 @@ 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,
visibilityPhones,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
@@ -136,10 +168,10 @@ 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>
<Typography.Text type="secondary">DETAIL 10MB</Typography.Text>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
<Divider />
<Form.Item name="storyTitle" label="故事标题">
@@ -180,13 +212,94 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
);
}
function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
<>
<Form.Item
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<>
<Form.Item
name="visibilityPhones"
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : 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 />
@@ -217,6 +330,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 +405,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, row) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</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 +462,7 @@ export default function ProductsPage() {
</Space>
),
},
];
], [detail, editForm]);
return (
<div>
@@ -309,12 +480,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)}
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('已保存');
@@ -331,7 +506,7 @@ export default function ProductsPage() {
</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: '详情页',
@@ -349,6 +524,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 +537,13 @@ export default function ProductsPage() {
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
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"
+120 -23
View File
@@ -1,17 +1,29 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
import { Button, 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;
user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
};
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? '—';
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
export default function RedeemRecordsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -21,28 +33,65 @@ export default function RedeemRecordsPage() {
const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.channel) qs.set('channel', filters.channel);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
async function openDetail(id: string) {
setDetailLoading(true);
setDrawerOpen(true);
try {
setDetail(await request(`/admin/redeem-records/${id}`));
} finally {
setDetailLoading(false);
}
}
const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
{
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 +99,69 @@ 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>
<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>
);
+45 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
Button, 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';
@@ -45,12 +45,33 @@ 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: 'phone', width: 120 },
@@ -218,10 +239,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>
+412 -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,61 @@ 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 [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
});
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 +83,131 @@ 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,
});
}, [filters.kind, filters.status, 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 +218,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 +333,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 +356,37 @@ export default function StoreBillsPage() {
form={form}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={{
kind: filters.kind || undefined,
status: filters.status || 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 +394,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 +424,7 @@ export default function StoreBillsPage() {
form.resetFields();
setFilters({});
setPage(1);
setSelectedKeys([]);
}}
>
@@ -245,17 +432,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 +474,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,331 @@
import { useEffect, useState } from 'react';
import {
Button,
Drawer,
Input,
Modal,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type {
StorePackageAuditDetailDto,
StorePackageChangeRequestDto,
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
const name = String(pkg.name ?? '').trim();
return name ? `name:${name}` : `idx:${index}`;
}
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;
}> = [];
for (const key of keys) {
const l = liveMap.get(key);
const p = proposedMap.get(key);
if (l && !p) {
rows.push({ key, change: 'removed', live: l });
} else if (!l && p) {
rows.push({ key, change: 'added', proposed: p });
} else if (l && p) {
const changed =
l.price !== p.price ||
l.dishes !== p.dishes ||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
}
}
return rows;
}
const CHANGE_LABELS = {
added: { text: '新增', color: 'green' },
removed: { text: '删除', color: 'red' },
changed: { text: '变更', color: 'orange' },
unchanged: { text: '未变', color: 'default' },
} as const;
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 [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<StorePackageAuditDetailDto | 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 = await request<Paginated<StorePackageChangeRequestDto>>(
`/admin/store-package-audits?${qs}`,
);
setItems(data.items);
setTotal(data.total);
setPage(data.page);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload(1, status);
}, [status]);
async function openDetail(id: string) {
setDetailOpen(true);
setDetailLoading(true);
setDetail(null);
try {
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${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-package-audits/${id}/audit`, {
method: 'PUT',
body: JSON.stringify(
action === 'REJECT' ? { action, rejectReason: reason } : { action },
),
});
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
setDetailOpen(false);
void reload(page, status);
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
}
}
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
{
title: '变更',
dataIndex: 'change',
width: 72,
render: (v: keyof typeof CHANGE_LABELS) => {
const meta = CHANGE_LABELS[v];
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{
title: '当前线上',
render: (_, row) =>
row.live ? (
<div>
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
{row.live.usableTime ? (
<div><Typography.Text type="secondary">{row.live.usableTime}</Typography.Text></div>
) : null}
{row.live.otherNotes ? (
<div><Typography.Text type="secondary">{row.live.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
},
{
title: '申请变更',
render: (_, row) =>
row.proposed ? (
<div>
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
{row.proposed.usableTime ? (
<div><Typography.Text type="secondary">{row.proposed.usableTime}</Typography.Text></div>
) : null}
{row.proposed.otherNotes ? (
<div><Typography.Text type="secondary">{row.proposed.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
},
];
const columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '状态',
dataIndex: 'status',
render: (v: StorePackageChangeRequestDto['status']) => (
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
),
},
{
title: '套餐数',
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={() => 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>
<Typography.Title level={4}></Typography.Title>
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
</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={720}
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 }}>
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
</Typography.Text>
</Space>
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<Typography.Paragraph type="secondary">
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0}
</Typography.Paragraph>
<Table
size="small"
rowKey="key"
columns={diffColumns}
dataSource={diffRows}
pagination={false}
/>
</>
) : 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
+130 -294
View File
@@ -1,573 +1,409 @@
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 [];
return meta.groups.map((group) => ({
key: group.key,
label: group.label,
forceRender: true,
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,
),
)}
</div>
),
}));
}, [meta, mockSmsEnabled, loading]);
async function onSave() {
const values = await form.validateFields();
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>
);
}
+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>
);
}
+64 -19
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,14 @@ type UserOrderRow = {
createdAt: string;
};
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 +74,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 +92,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);
@@ -117,8 +132,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);
}
@@ -222,7 +243,7 @@ export default function UsersPage() {
title: '手机',
dataIndex: 'phone',
width: 120,
render: (v) => v || '—',
render: (v) => maskPhone(v),
},
{
title: '验手机',
@@ -309,13 +330,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="手机号">
@@ -353,11 +376,11 @@ export default function UsersPage() {
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
rowSelection={{
rowSelection={canDeleteUsers ? {
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
} : undefined}
pagination={{
current: page,
pageSize,
@@ -375,9 +398,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 +408,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,7 +442,7 @@ 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>
@@ -429,6 +452,28 @@ export default function UsersPage() {
</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 +638,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,405 @@
import { useState } from 'react';
import {
Avatar,
Button,
Checkbox,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WECOM_PUSH_CONDITION_GROUPS,
WECOM_PUSH_CONDITION_LABELS,
type WecomMessagePushDto,
type WecomPushCondition,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type FormValues = {
name: string;
avatarUrl?: string;
webhookUrl: string;
enabled: boolean;
mentionWecomUserId?: string;
pushConditions: WecomPushCondition[];
sortOrder: number;
};
function WecomPushConditionPicker({
value,
onChange,
}: {
value?: WecomPushCondition[];
onChange?: (next: WecomPushCondition[]) => void;
}) {
const selected = new Set(value ?? []);
function toggle(cond: WecomPushCondition, checked: boolean) {
const next = new Set(selected);
if (checked) next.add(cond);
else next.delete(cond);
onChange?.([...next]);
}
return (
<div style={{ width: '100%' }}>
{WECOM_PUSH_CONDITION_GROUPS.map((group) => (
<div key={group.key} style={{ marginBottom: 12 }}>
<Typography.Text strong>{group.label}</Typography.Text>
<div style={{ marginTop: 8 }}>
{group.conditions.map((c) => (
<div key={c}>
<Checkbox
checked={selected.has(c)}
onChange={(e) => toggle(c, e.target.checked)}
>
{WECOM_PUSH_CONDITION_LABELS[c]}
</Checkbox>
</div>
))}
</div>
</div>
))}
</div>
);
}
export default function WecomMessagePushesPage() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<WecomMessagePushDto | null>(null);
const [saving, setSaving] = useState(false);
const [detail, setDetail] = useState<WecomMessagePushDto | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<WecomMessagePushDto>(
'/admin/wecom-message-pushes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
const conditionsWatch = Form.useWatch('pushConditions', form) as WecomPushCondition[] | undefined;
function openCreate() {
setEditing(null);
form.resetFields();
form.setFieldsValue({
enabled: true,
pushConditions: ['alert.ops'],
sortOrder: 0,
});
setModalOpen(true);
}
function openEdit(row: WecomMessagePushDto) {
setEditing(row);
form.setFieldsValue({
name: row.name,
avatarUrl: row.avatarUrl ?? undefined,
webhookUrl: row.webhookUrl,
enabled: row.enabled,
mentionWecomUserId: row.mentionWecomUserId ?? undefined,
pushConditions: row.pushConditions,
sortOrder: row.sortOrder,
});
setModalOpen(true);
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const body = {
name: values.name,
avatarUrl: values.avatarUrl || null,
webhookUrl: values.webhookUrl,
enabled: values.enabled,
mentionWecomUserId: values.mentionWecomUserId?.trim() || null,
pushConditions: values.pushConditions,
sortOrder: values.sortOrder ?? 0,
};
if (editing) {
await request(`/admin/wecom-message-pushes/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
message.success('已更新');
} else {
await request('/admin/wecom-message-pushes', {
method: 'POST',
body: JSON.stringify(body),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function remove(id: string) {
try {
await request(`/admin/wecom-message-pushes/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function testPush(id: string) {
setTestingId(id);
try {
const res = await request<{ ok: boolean; message: string }>(
`/admin/wecom-message-pushes/${id}/test`,
{ method: 'POST', body: '{}' },
);
message.success(res.message || '已发送测试消息');
} catch (e) {
message.error(e instanceof Error ? e.message : '测试失败');
} finally {
setTestingId(null);
}
}
const columns: ColumnsType<WecomMessagePushDto> = [
{
title: '名称',
dataIndex: 'name',
render: (name: string, row) => (
<Space>
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
<span>{name}</span>
</Space>
),
},
{
title: 'Webhook',
dataIndex: 'webhookUrlMasked',
ellipsis: true,
},
{
title: '推送条件',
dataIndex: 'pushConditions',
render: (conds: WecomPushCondition[]) =>
conds.map((c) => (
<Tag key={c} style={{ marginBottom: 4 }}>
{WECOM_PUSH_CONDITION_LABELS[c]}
</Tag>
)),
},
{
title: '启用',
dataIndex: 'enabled',
width: 72,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{ title: '排序', dataIndex: 'sortOrder', width: 64 },
{
title: '操作',
key: 'actions',
width: 220,
render: (_, row) => (
<Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Button
type="link"
size="small"
loading={testingId === row.id}
onClick={() => void testPush(row.id)}
>
</Button>
<Button
type="link"
size="small"
onClick={() => {
void request<WecomMessagePushDto>(`/admin/wecom-message-pushes/${row.id}`).then(setDetail);
}}
>
</Button>
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Typography.Paragraph type="secondary">
Webhook
.env Webhook URL
</Typography.Paragraph>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => setFilters(v as Record<string, string>)}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="搜索" />
</Form.Item>
<Form.Item name="enabled" label="启用">
<Select
allowClear
placeholder="全部"
style={{ width: 100 }}
options={[
{ value: 'true', label: '启用' },
{ value: 'false', label: '停用' },
]}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
}}
>
</Button>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
</Form.Item>
</Form>
<Table<WecomMessagePushDto>
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑消息推送' : '新建消息推送'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void save()}
confirmLoading={saving}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:运营告警" />
</Form.Item>
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
<OssUpload />
</Form.Item>
<Form.Item
name="webhookUrl"
label="Webhook URL"
rules={[{ required: true, message: '请填写 Webhook URL' }]}
>
<Input.Password placeholder="企微群机器人 Webhook" />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name="mentionWecomUserId"
label="企微 userid(可选)"
extra="markdown 推送用 <@userid> @ 群成员;任务派发等场景生效"
>
<Input placeholder="如 woDacESQAAD..." allowClear />
</Form.Item>
<Form.Item
name="pushConditions"
label="推送条件"
rules={[
{
validator: (_, v: WecomPushCondition[] | undefined) =>
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少勾选一项推送条件')),
},
]}
>
<WecomPushConditionPicker />
</Form.Item>
{conditionsWatch?.length ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
{conditionsWatch.length}
{conditionsWatch.map((c) => WECOM_PUSH_CONDITION_LABELS[c]).join('、')}
</Typography.Text>
) : null}
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
title="消息推送详情"
open={!!detail}
onCancel={() => setDetail(null)}
footer={null}
width={560}
>
{detail ? (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="Webhook">{detail.webhookUrlMasked}</Descriptions.Item>
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="userid">{detail.mentionWecomUserId || '—'}</Descriptions.Item>
<Descriptions.Item label="推送条件">
{detail.pushConditions.map((c) => WECOM_PUSH_CONDITION_LABELS[c]).join('、')}
</Descriptions.Item>
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
</Descriptions>
) : null}
</Modal>
</div>
);
}
+302 -54
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,24 +18,44 @@ 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;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
deliveryType: string;
payAmount: number;
wineryAmount: number;
paidAt: string;
productName?: string;
quantity?: number;
};
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
};
const DELIVERY_LABELS: Record<string, string> = {
@@ -39,57 +63,146 @@ const DELIVERY_LABELS: Record<string, string> = {
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)}`,
},
{
@@ -105,30 +218,62 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 170,
render: (v) => (v ? fmtTime(v) : '—'),
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.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}%绿
</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,28 +282,30 @@ 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="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="deliveryType" label="配送类型">
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
]}
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="range" label="账单日">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
@@ -180,15 +327,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: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
@@ -200,6 +361,93 @@ 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="状态">{STATUS_LABELS[detail.status] || detail.status}</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>
);
}
+4 -1
View File
@@ -9,15 +9,18 @@
"lint": "echo ok"
},
"dependencies": {
"@dukang/shared-ui": "workspace:*",
"@dukang/client-logging": "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>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess';
const PUBLIC_PATHS = new Set(['/login']);
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
export default function AuthGate({ children }: { children: React.ReactNode }) {
const { ready, authenticated, account } = usePartnerSession();
+63 -111
View File
@@ -1,17 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
import { enqueueUpload } from '../lib/upload-lock';
import {
authorizePartnerWechat,
fetchClientConfig,
fetchPartnerProfile,
needsWechatAuth,
type PartnerProfile,
} from '../lib/wechat-auth';
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError } from '../lib/toast';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
type OssUploadFieldProps = {
value?: string;
@@ -22,12 +14,14 @@ type OssUploadFieldProps = {
wide?: boolean;
compact?: boolean;
label?: string;
/** 父级已确认微信授权时可跳过检查 */
/** 兼容现有调用:选图不再依赖 openId 绑定 */
wechatReady?: boolean;
onWechatReadyChange?: (ready: boolean) => void;
};
const DEFAULT_MAX_MB = 10;
function isCancelError(msg: string): boolean {
return /cancel|取消/i.test(msg);
}
function formatWechatUploadError(e: unknown): string {
const msg = e instanceof Error ? e.message : '无法打开相册';
@@ -52,15 +46,12 @@ export default function OssUploadField({
wide,
compact,
label,
wechatReady,
onWechatReadyChange,
}: OssUploadFieldProps) {
const inputRef = useRef<HTMLInputElement>(null);
const pickingRef = useRef(false);
const [uploading, setUploading] = useState(false);
const [authorizing, setAuthorizing] = useState(false);
const [error, setError] = useState('');
const [profile, setProfile] = useState<PartnerProfile | null>(null);
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
function showUploadError(text: string) {
setError(text);
@@ -72,42 +63,20 @@ export default function OssUploadField({
const inWechat = isWechatEnv();
const useWechatPicker =
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
const onWechatReadyChangeRef = useRef(onWechatReadyChange);
onWechatReadyChangeRef.current = onWechatReadyChange;
useEffect(() => {
if (!useWechatPicker) return;
void fetchClientConfig()
.then(setClientConfig)
.catch(() => {
/* 未登录等场景由上传接口报错 */
});
if (wechatReady === true) return;
void fetchPartnerProfile()
.then(setProfile)
.catch(() => {
/* 未登录等场景由上传接口报错 */
});
}, [useWechatPicker, wechatReady]);
useEffect(() => {
if (!useWechatPicker || needsAuth) return;
weixinSdk.reset();
// 仅预热,勿在每次点击时 reset,否则取消后再点常无法调起
void weixinSdk.init().catch(() => {
/* 点击上传时会再次初始化 */
});
}, [useWechatPicker, needsAuth]);
}, [useWechatPicker]);
async function persistUpload(file: File) {
if (!file.size) {
showUploadError('图片读取失败,请重新选择');
return;
}
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
return;
}
const result = await uploadFileToOss(file, { bizType, mediaType });
onChange?.(result.url);
}
@@ -125,65 +94,50 @@ export default function OssUploadField({
}
}
async function startWechatAuth() {
setAuthorizing(true);
async function pickWechatImage() {
if (pickingRef.current || uploading) return;
pickingRef.current = true;
setError('');
try {
const result = await authorizePartnerWechat();
if (result) {
const me = await fetchPartnerProfile();
setProfile(me);
if (me.hasWechat) onWechatReadyChangeRef.current?.(true);
// 不要每次 reset:会打断 JSSDK,取消后再点经常无法调起相机/相册
await weixinSdk.init();
const files = await weixinSdk.chooseImages({
count: 1,
sourceType: ['album', 'camera'],
});
// 取消或未选图:直接结束,允许再次点击
if (!files?.length) return;
setUploading(true);
try {
// 仅串行化实际上传,选图本身不占上传锁,避免取消后锁态异常
await enqueueUpload(() => persistUpload(files[0]!));
} finally {
setUploading(false);
}
} catch (e) {
const text = e instanceof Error ? e.message : '微信授权失败';
showUploadError(text);
} finally {
setAuthorizing(false);
}
}
async function pickWechatImage() {
setUploading(true);
setError('');
try {
weixinSdk.reset();
await weixinSdk.init();
// 选图 + 上传须在同一个队列任务内完成,避免嵌套 enqueueUpload 死锁
await enqueueUpload(async () => {
const files = await weixinSdk.chooseImages({
count: 1,
sourceType: ['album', 'camera'],
});
if (!files?.[0]) return;
await persistUpload(files[0]);
});
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e));
if (isCancelError(msg)) return;
throw e;
} finally {
setUploading(false);
pickingRef.current = false;
}
}
async function pickFile() {
if (uploading || authorizing) return;
if (uploading || pickingRef.current) return;
setError('');
if (useWechatPicker && needsAuth) {
const text = '请先完成微信授权后再上传照片';
showUploadError(text);
return;
}
if (useWechatPicker) {
try {
await pickWechatImage();
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e));
if (isCancelError(msg)) return;
const formatted = formatWechatUploadError(e);
setError(`${formatted},可改从系统相册选择`);
setShowAlbumFallback(true);
inputRef.current?.click();
}
return;
}
@@ -193,44 +147,31 @@ export default function OssUploadField({
const isImage = mediaType === 'IMAGE' && value;
const isFile = mediaType === 'FILE' && value;
const busy = uploading || authorizing;
const busy = uploading;
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
const triggerProps = {
type: 'button' as const,
disabled: busy || needsAuth,
disabled: busy,
onClick: () => void pickFile(),
};
return (
<div className="partner-oss-upload">
{needsAuth && (
<div className="partner-wechat-auth-hint" role="status">
<p className="body-md"></p>
<button
type="button"
className="partner-btn-outline"
style={{ marginTop: 8, width: '100%' }}
disabled={authorizing}
onClick={() => void startWechatAuth()}
>
{authorizing ? '跳转授权中…' : '微信授权绑定'}
</button>
</div>
)}
{!useWechatPicker && (
<input
ref={inputRef}
type="file"
accept={resolvedAccept}
className="partner-oss-upload-input"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void uploadSelectedFile(file);
}}
/>
)}
<input
ref={inputRef}
type="file"
accept={resolvedAccept}
className="partner-oss-upload-input"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setShowAlbumFallback(false);
void uploadSelectedFile(file);
}
}}
/>
{isImage ? (
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
<img src={value} alt={label ?? '已上传'} />
@@ -258,10 +199,21 @@ export default function OssUploadField({
{busy ? 'hourglass_top' : 'add_a_photo'}
</span>
<span className="text-primary" style={{ fontWeight: 500 }}>
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
{uploading ? '上传中…' : pickerLabel}
</span>
</button>
)}
{showAlbumFallback && useWechatPicker && (
<button
type="button"
className="partner-btn-outline"
style={{ marginTop: 8, width: '100%' }}
disabled={busy}
onClick={() => inputRef.current?.click()}
>
</button>
)}
{error && <p className="partner-form-error" role="alert">{error}</p>}
</div>
);
@@ -0,0 +1,169 @@
import { useState } from 'react';
import type { PackageFormItem } from '../lib/storePackages';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { emptyPackage } from '../lib/storePackages';
import OssUploadField from './OssUploadField';
type Props = {
items: PackageFormItem[];
onChange: (items: PackageFormItem[]) => void;
disabled?: boolean;
/** 嵌入拓店流程时不重复外层卡片边距 */
embedded?: boolean;
};
export default function StorePackagesForm({ items, onChange, disabled, embedded }: Props) {
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
function updateAt(index: number, patch: Partial<PackageFormItem>) {
const next = items.map((item, i) => (i === index ? { ...item, ...patch } : item));
onChange(next);
}
function addItem() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
onChange([...items, emptyPackage(items.length)]);
}
function removeAt(index: number) {
onChange(items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
setCollapsed((prev) => {
const next: Record<number, boolean> = {};
Object.entries(prev).forEach(([k, v]) => {
const i = Number(k);
if (i < index) next[i] = v;
else if (i > index) next[i - 1] = v;
});
return next;
});
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
const list = items.length ? items : [emptyPackage(0)];
return (
<div className={`partner-packages-form${embedded ? ' partner-packages-form--embedded' : ''}`}>
{list.map((item, index) => {
const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`;
return (
<section
key={index}
className={`partner-form-card partner-packages-item${isCollapsed ? ' partner-packages-item--collapsed' : ''}`}
style={embedded ? { marginLeft: 0, marginRight: 0 } : undefined}
>
<div className="partner-packages-item-head">
<button
type="button"
className="partner-packages-toggle"
onClick={() => toggleCollapse(index)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? '展开套餐' : '收起套餐'}
>
<span className="material-symbols-outlined">
{isCollapsed ? 'expand_more' : 'expand_less'}
</span>
<span className="headline-md">{displayName}</span>
</button>
{!disabled && list.length > 1 ? (
<button type="button" className="partner-packages-remove" onClick={() => removeAt(index)}>
</button>
) : null}
</div>
{!isCollapsed ? (
<>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">restaurant</span>
<input
placeholder="如:套餐A"
value={item.name}
disabled={disabled}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">payments</span>
<input
type="number"
min={0}
step={0.01}
placeholder="198"
value={item.price}
disabled={disabled}
onChange={(e) => updateAt(index, { price: e.target.value })}
/>
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<textarea
rows={3}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
disabled={disabled}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
/>
</div>
<div className="partner-field">
<label>使</label>
<div className="partner-field-input">
<span className="material-symbols-outlined">schedule</span>
<input
placeholder="节假日除外"
value={item.usableTime || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</div>
</div>
<div className="partner-field">
<label></label>
<OssUploadField
bizType="STORE_PACKAGE"
value={item.imageUrl || ''}
disabled={disabled}
onChange={(url) => updateAt(index, { imageUrl: url })}
/>
</div>
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">info</span>
<input
placeholder="不可叠加"
value={item.otherNotes || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</div>
</div>
</>
) : null}
</section>
);
})}
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
<button type="button" className="partner-packages-add" onClick={addItem}>
<span className="material-symbols-outlined">add_circle</span>
</button>
) : null}
</div>
);
}
@@ -0,0 +1,234 @@
import { useEffect, useMemo, useState } from 'react';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
import { request } from '../lib/api';
import {
buildTencentLocPickerUrl,
parseTencentLocPickerMessage,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
type Props = {
open: boolean;
onClose: () => void;
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
/** 保留兼容,iframe 选点不使用 */
region?: string | null;
};
let cachedKey: string | null | undefined;
async function loadTencentLbsKey(): Promise<string> {
if (cachedKey) return cachedKey;
const cfg = await request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config', {
silent: true,
});
const key = (cfg.tencentLbsKey || '').trim();
if (!key) {
throw new Error('未配置腾讯位置服务 Key,请联系管理员在系统设置中配置');
}
cachedKey = key;
return key;
}
export default function TencentLocPickerOverlay({
open,
onClose,
onPick,
latitude,
longitude,
}: Props) {
const [key, setKey] = useState<string | null>(cachedKey ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
/** 选点结果仅存内存,界面不展示经纬度 */
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [iframeReady, setIframeReady] = useState(false);
useEffect(() => {
if (!open) {
setPending(null);
setIframeReady(false);
setError('');
return;
}
let cancelled = false;
if (key) return;
setLoading(true);
void loadTencentLbsKey()
.then((k) => {
if (!cancelled) setKey(k);
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : '加载地图配置失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, key]);
useEffect(() => {
if (!open) return;
function onMessage(event: MessageEvent) {
const picked = parseTencentLocPickerMessage(event.data);
if (!picked) return;
setPending(picked);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [open]);
const src = useMemo(() => {
if (!key) return '';
const lat = latitude != null ? Number(latitude) : undefined;
const lng = longitude != null ? Number(longitude) : undefined;
return buildTencentLocPickerUrl(key, {
latitude: lat != null && Number.isFinite(lat) ? lat : undefined,
longitude: lng != null && Number.isFinite(lng) ? lng : undefined,
});
}, [key, latitude, longitude]);
if (!open) return null;
function confirmPick() {
if (!pending) return;
onPick(pending);
onClose();
}
return (
<div
className="partner-locpicker-overlay"
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: '#fff',
display: 'flex',
flexDirection: 'column',
height: '100dvh',
maxHeight: '-webkit-fill-available',
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
boxSizing: 'border-box',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '12px 12px 10px',
borderBottom: '1px solid rgba(0,0,0,0.06)',
flexShrink: 0,
gap: 10,
background: '#fff',
}}
>
<h2
style={{
margin: 0,
textAlign: 'center',
fontWeight: 700,
fontSize: 17,
lineHeight: 1.3,
}}
>
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
type="button"
className="partner-btn-primary"
style={{
flex: 1,
padding: '12px 16px',
fontSize: 15,
fontWeight: 600,
opacity: pending ? 1 : 0.45,
width: 'auto',
}}
disabled={!pending}
onClick={confirmPick}
>
</button>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '12px 16px', minWidth: 72, flexShrink: 0 }}
onClick={onClose}
>
</button>
</div>
</div>
<p
className="label-md text-muted"
style={{
margin: 0,
padding: '8px 14px',
borderBottom: '1px solid rgba(0,0,0,0.04)',
flexShrink: 0,
fontSize: 12,
}}
>
{pending ? '已选定位置,请点右上角「确认」' : '在地图上点选或搜索地点,选中后点右上角「确认」'}
</p>
<div style={{ flex: 1, minHeight: 0, position: 'relative', background: '#f5f5f5' }}>
{loading ? (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
</p>
) : error ? (
<div style={{ padding: 24 }}>
<p className="label-md" style={{ color: 'var(--color-heritage-red, #a61d24)' }}>
{error}
</p>
<p className="label-md text-muted" style={{ marginTop: 12 }}>
Key apis.map.qq.com
</p>
</div>
) : src ? (
<>
{!iframeReady ? (
<p
className="label-md text-muted"
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: 0,
zIndex: 1,
pointerEvents: 'none',
}}
>
</p>
) : null}
<iframe
title="地图选点"
src={src}
allow="geolocation *"
onLoad={() => setIframeReady(true)}
style={{
width: '100%',
height: '100%',
border: 0,
display: 'block',
background: '#fff',
}}
/>
</>
) : null}
</div>
</div>
);
}
@@ -50,7 +50,13 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
staffRole: profile.staffRole,
permissions: profile.permissions,
primaryAccountId: profile.primaryAccountId,
primaryPhone: profile.primaryPhone,
primaryName: profile.primaryName,
hasWechat: profile.hasWechat,
wxNickname: profile.wxNickname,
wxAvatarUrl: profile.wxAvatarUrl,
managedWarehouseId: profile.managedWarehouseId,
hasWarehouseAccess: profile.hasWarehouseAccess,
};
}
@@ -1,17 +1,30 @@
import { NavLink, Outlet } from 'react-router-dom';
import type { PartnerNavKind } from '../lib/partnerAccess';
const TABS = [
{ to: '/stores', end: true, icon: 'store', label: '门店管理' },
{ to: '/stores/new', icon: 'add_business', label: '录入门店' },
{ to: '/me', icon: 'person', label: '我的' },
const STORE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/stores', icon: 'store', label: '门店管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
export default function SubAccountLayout() {
const WAREHOUSE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
type SubAccountLayoutProps = {
navKind: PartnerNavKind;
};
export default function SubAccountLayout({ navKind }: SubAccountLayoutProps) {
const tabs = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_TABS : STORE_STAFF_TABS;
return (
<>
<Outlet />
<nav className="app-tabbar">
{TABS.map((tab) => (
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
+16
View File
@@ -0,0 +1,16 @@
import { createPartnerTracker } from '@dukang/client-logging';
import { apiBase } from './api';
const tracker = createPartnerTracker({
apiBase,
clientApp: 'PARTNER_H5',
getToken: () => localStorage.getItem('partnerAccessToken'),
});
export function trackPartner(eventName: string, params?: Record<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPartnerPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
+40 -5
View File
@@ -1,4 +1,5 @@
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
import { reportApiError } from '@dukang/client-logging';
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
import { showPartnerToast } from './toast';
@@ -12,7 +13,7 @@ const PARTNER_PROFILE = 'partnerProfile';
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
export const PARTNER_WX_BOUND = 'partnerWxBound';
/** 微信验证通过后的免登录时长 */
/** 手机号或微信验证通过后的免登录时长 */
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -24,7 +25,20 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
export type PartnerSessionProfile = Pick<
PartnerMe,
'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'hasWechat'
| 'id'
| 'name'
| 'phone'
| 'companyName'
| 'isPrimary'
| 'permissions'
| 'primaryAccountId'
| 'primaryPhone'
| 'primaryName'
| 'hasWechat'
| 'wxNickname'
| 'wxAvatarUrl'
| 'managedWarehouseId'
| 'hasWarehouseAccess'
> & {
staffRole?: PartnerStaffRole;
};
@@ -64,7 +78,7 @@ export function isPartnerSessionExpired() {
}
export function touchPartnerSession() {
if (!hasPartnerWxSession()) return;
if (!localStorage.getItem(REFRESH_TOKEN)) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
@@ -77,11 +91,16 @@ export function saveAuth(data: PartnerSessionPayload) {
}
}
/** 手机号验证成功后写入 7 天免验证码会话 */
export function saveRememberedSession(data: PartnerSessionPayload) {
saveAuth(data);
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
/** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: PartnerSessionPayload) {
saveAuth(data);
saveRememberedSession(data);
localStorage.setItem(PARTNER_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
export function clearAuth(options?: { keepProfile?: boolean }) {
@@ -95,6 +114,10 @@ export function clearAuth(options?: { keepProfile?: boolean }) {
}
}
export function getToken() {
return localStorage.getItem(ACCESS_TOKEN);
}
export function isLoggedIn() {
return !!localStorage.getItem(ACCESS_TOKEN);
}
@@ -109,7 +132,13 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
staffRole: me.staffRole ?? undefined,
permissions: me.permissions,
primaryAccountId: me.primaryAccountId,
primaryPhone: me.primaryPhone,
primaryName: me.primaryName,
hasWechat: me.hasWechat,
wxNickname: me.wxNickname,
wxAvatarUrl: me.wxAvatarUrl,
managedWarehouseId: me.managedWarehouseId,
hasWarehouseAccess: me.hasWarehouseAccess,
};
}
@@ -131,6 +160,12 @@ async function rawRequest<T>(
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code === 401 ? 401 : json.code;
if (json.code === 400) {
reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
);
}
throw err;
}
return json.data as T;
+90
View File
@@ -0,0 +1,90 @@
import { apiBase, getToken } from './api';
const CLIENT_APP = 'PARTNER_H5';
export type ClientErrorLevel = 'fatal' | 'error' | 'warn';
export type ClientErrorCategory =
| 'js_error'
| 'unhandled_rejection'
| 'api_error'
| 'network'
| 'render'
| 'bridge'
| 'other';
export type ClientErrorPayload = {
level: ClientErrorLevel;
category: ClientErrorCategory;
message: string;
stack?: string;
pagePath?: string;
extra?: Record<string, unknown>;
};
function currentPagePath(): string | undefined {
try {
return typeof window !== 'undefined' ? window.location.pathname : undefined;
} catch {
return undefined;
}
}
/** 上报客户端错误(失败静默,避免递归) */
export function reportClientError(payload: ClientErrorPayload): void {
const body = {
level: payload.level,
category: payload.category,
message: String(payload.message || 'unknown').slice(0, 1000),
stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined,
pagePath: (payload.pagePath || currentPagePath() || '').slice(0, 128) || undefined,
clientApp: CLIENT_APP,
extra: payload.extra,
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
void fetch(`${apiBase}/common/client-errors`, {
method: 'POST',
headers,
body: JSON.stringify(body),
keepalive: true,
}).catch(() => {});
}
let installed = false;
/** 安装 H5 全局未捕获错误钩子(幂等) */
export function installClientErrorReporting(): void {
if (installed || typeof window === 'undefined') return;
installed = true;
window.addEventListener('error', (ev) => {
reportClientError({
level: 'fatal',
category: 'js_error',
message: ev.message || 'window.error',
stack: ev.error instanceof Error ? ev.error.stack : undefined,
extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno },
});
});
window.addEventListener('unhandledrejection', (ev) => {
const reason = ev.reason;
reportClientError({
level: 'error',
category: 'unhandled_rejection',
message:
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: 'unhandledrejection',
stack: reason instanceof Error ? reason.stack : undefined,
});
});
}
+17
View File
@@ -0,0 +1,17 @@
/** 合伙人端客服热线(可后续改为环境变量) */
export const PARTNER_SUPPORT_PHONE = '400-000-0000';
export function dialPhone(phone: string) {
const normalized = phone.replace(/\s+/g, '');
if (!normalized) return false;
window.location.href = `tel:${normalized}`;
return true;
}
export function contactSupport() {
return dialPhone(PARTNER_SUPPORT_PHONE);
}
export function contactPartnerPhone(phone?: string) {
return dialPhone(phone ?? '');
}
+1
View File
@@ -5,6 +5,7 @@ export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'tota
return request<PartnerLeaderboardResponse>(
'PARTNER_H5',
`/partner/dashboard/leaderboard?period=${period}`,
{ silent: true },
);
}
+80 -5
View File
@@ -1,5 +1,7 @@
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
return account?.isPrimary !== false;
}
@@ -17,15 +19,88 @@ export function hasPartnerPermission(
return account.permissions?.includes(permission) ?? false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return isSubAccount(account) ? '/stores/new?step=1' : '/';
export function hasAnyPartnerPermission(
account: PartnerMe | null | undefined,
permissions: PartnerPermissionKey[],
): boolean {
return permissions.some((p) => hasPartnerPermission(account, p));
}
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/me', '/login'];
/** 与后端 GET /partner/dashboard 权限点一致 */
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, [
'store:create',
'store:manage',
'order:view',
'warehouse:manage',
]);
}
export function isSubAccountPath(pathname: string): boolean {
/** 门店列表/拓店统计所需权限 */
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
}
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 录入新店 */
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasPartnerPermission(account, 'store:create')) return true;
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 与后端 GET /partner/orders 权限点一致 */
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
}
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
if (!account || isPrimaryAccount(account)) return false;
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
const hasStore =
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
return false;
}
export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
if (!account || isPrimaryAccount(account)) return 'primary';
if (isWarehouseStaff(account)) return 'warehouse_staff';
return 'store_staff';
}
/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */
export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (account.hasWarehouseAccess === false) return false;
if (account.hasWarehouseAccess === true) return true;
// 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单
return false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return '/';
}
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
if (pathname === '/login') return true;
return SUB_ACCOUNT_ALLOWED_PREFIXES.some(
const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
return prefixes.some(
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
);
}
@@ -0,0 +1,56 @@
import type { PartnerProxyDeliveryMode } from '@dukang/shared-types';
const PROXY_ORDER_DRAFT_KEY = 'partner_proxy_order_draft_v2';
export type ProxyOrderDraft = {
phone: string;
receiverName: string;
regionCodes: string[];
addressDetail: string;
productId: string;
quantity: number;
promoCodeId: string;
deliveryMode: PartnerProxyDeliveryMode;
autoReceive: boolean;
};
export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
try {
sessionStorage.setItem(PROXY_ORDER_DRAFT_KEY, JSON.stringify(draft));
} catch {
/* ignore quota / private mode */
}
}
export function loadProxyOrderDraft(): ProxyOrderDraft | null {
try {
const raw = sessionStorage.getItem(PROXY_ORDER_DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<ProxyOrderDraft>;
if (!parsed || typeof parsed !== 'object') return null;
return {
phone: typeof parsed.phone === 'string' ? parsed.phone : '',
receiverName: typeof parsed.receiverName === 'string' ? parsed.receiverName : '',
regionCodes: Array.isArray(parsed.regionCodes)
? parsed.regionCodes.filter((c): c is string => typeof c === 'string')
: [],
addressDetail: typeof parsed.addressDetail === 'string' ? parsed.addressDetail : '',
productId: typeof parsed.productId === 'string' ? parsed.productId : '',
quantity: typeof parsed.quantity === 'number' && parsed.quantity >= 1 ? parsed.quantity : 2,
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
autoReceive: parsed.autoReceive === true,
};
} catch {
return null;
}
}
export function clearProxyOrderDraft() {
try {
sessionStorage.removeItem(PROXY_ORDER_DRAFT_KEY);
sessionStorage.removeItem('partner_proxy_order_draft_v1');
} catch {
/* ignore */
}
}
@@ -0,0 +1,36 @@
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
const PAY_LABELS: Record<string, string> = {
UNPAID: '未支付',
PAYING: '支付中',
PAID: '已付款',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
export function proxyOrderStatusLabel(status?: string | null) {
if (!status) return '—';
return STATUS_LABELS[status] || status;
}
export function proxyOrderPayLabel(payStatus?: string | null) {
if (!payStatus) return '—';
return PAY_LABELS[payStatus] || payStatus;
}
export function proxyOrderStatusColor(status?: string | null) {
if (status === 'PENDING_PAY') return 'var(--color-warning, #d48806)';
if (status === 'COMPLETED') return 'var(--color-success-green)';
if (status === 'CANCELLED' || status === 'REFUNDED') return 'var(--color-text-muted, #999)';
return 'var(--color-primary, #8b1e1e)';
}
+6 -7
View File
@@ -1,9 +1,5 @@
import {
PartnerStaffRole,
type CreatePartnerStaffRequest,
type PartnerStaffItem,
type UpdatePartnerStaffRequest,
} from '@dukang/shared-types';
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
import { request } from './api';
export function listPartnerStaff() {
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
name: body.name,
phone: body.phone,
smsCode: body.smsCode,
staffRole: PartnerStaffRole.INTERNAL,
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: body.permissions?.length
? body.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
}),
});
}
+127 -46
View File
@@ -6,15 +6,31 @@ export type StoreDraftForm = {
district: string;
name: string;
phone: string;
storeSmsCode: string;
address: string;
/** 门店坐标(定位或地理编码) */
latitude: string;
longitude: string;
openTime: string;
closeTime: string;
/** 是否启用第二段营业时间 */
dualHours: boolean;
openTime2: string;
closeTime2: string;
/** 人均费用(选填) */
avgPrice: string;
categoryParentId: string;
categoryId: string;
intro: string;
/** 好客权益券使用规则 */
benefitUsageRule: string;
coverUrl: string;
envPhotoUrls: string[];
contractUrl: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
/** 门店套餐(拓店第 4 步,可空) */
packages: import('@dukang/shared-types').StorePackageItemDto[];
};
export type StoreDraft = {
@@ -35,46 +51,76 @@ export const defaultStoreForm = (): StoreDraftForm => ({
cityId: '',
name: '',
phone: '',
storeSmsCode: '',
address: '',
latitude: '',
longitude: '',
openTime: '10:00',
closeTime: '22:00',
dualHours: false,
openTime2: '17:00',
closeTime2: '21:00',
avgPrice: '',
categoryParentId: '',
categoryId: '',
intro: '',
benefitUsageRule: '',
coverUrl: '',
envPhotoUrls: ['', '', ''],
contractUrl: '',
bankAccountName: '',
bankAccountNo: '',
bankBranch: '',
packages: [],
});
export function normalizeStringArray(raw: unknown, length: number): string[] {
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
const items = raw.map((item) => String(item ?? ''));
while (items.length < length) items.push('');
return items.slice(0, length);
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
const PHONE_RE = /^1[3-9]\d{9}$/;
const BANK_RE = /^\d{16,19}$/;
function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
export const MIN_ENV_PHOTO_COUNT = 3;
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
while (arr.length < minLen) arr.push('');
return arr;
}
export function addEnvPhotoSlot(urls: string[]): string[] {
return [...urls, ''];
}
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
const base = defaultStoreForm();
const regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3
? raw.regionCodes.map((code) => String(code))
: base.regionCodes;
if (!raw) return base;
return {
regionCodes,
...base,
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId),
province: String(raw.province ?? base.province),
city: String(raw.city ?? base.city),
district: String(raw.district ?? base.district),
name: String(raw.name ?? base.name),
phone: String(raw.phone ?? base.phone),
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
address: String(raw.address ?? base.address),
intro: String(raw.intro ?? base.intro),
coverUrl: String(raw.coverUrl ?? base.coverUrl),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
contractUrl: String(raw.contractUrl ?? base.contractUrl),
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
bankBranch: String(raw.bankBranch ?? base.bankBranch),
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
openTime: String(raw.openTime ?? base.openTime),
closeTime: String(raw.closeTime ?? base.closeTime),
dualHours: Boolean(raw.dualHours),
openTime2: String(raw.openTime2 ?? base.openTime2),
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
avgPrice: String(raw.avgPrice ?? base.avgPrice),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
packages: Array.isArray(raw.packages)
? raw.packages.map((p, i) => ({
name: String((p as { name?: string }).name ?? ''),
price: String((p as { price?: string | number }).price ?? ''),
dishes: String((p as { dishes?: string }).dishes ?? ''),
usableTime: (p as { usableTime?: string }).usableTime ?? '',
otherNotes: (p as { otherNotes?: string }).otherNotes ?? '',
sortOrder: i,
}))
: [],
};
}
@@ -90,14 +136,11 @@ export function loadStoreDraft(accountId?: string): StoreDraft | null {
}
}
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (parsed.form && typeof parsed.form === 'object') {
return {
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
form: normalizeForm(parsed.form as Record<string, unknown>),
};
}
return { step: 1, form: normalizeForm(parsed) };
const parsed = JSON.parse(raw) as StoreDraft;
return {
step: Number(parsed.step) || 0,
form: normalizeStoreDraftForm(parsed.form),
};
} catch {
return null;
}
@@ -117,26 +160,58 @@ export function clearAllStoreDrafts(accountId?: string) {
localStorage.removeItem(STORE_DRAFT_KEY);
}
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
export function validateStoreStep1(
form: Pick<
StoreDraftForm,
'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro'
| 'regionCodes'
| 'cityId'
| 'name'
| 'address'
| 'openTime'
| 'closeTime'
| 'dualHours'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'categoryId'
| 'intro'
| 'benefitUsageRule'
>,
): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
if (!form.name.trim()) return '请填写门店名称';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
if (!form.address.trim()) return '请填写详细地址';
if (!form.openTime.trim()) return '请填写营业开始时间';
if (!TIME_RE.test(form.openTime.trim())) return '营业开始时间格式须为 HH:MM';
if (!form.closeTime.trim()) return '请填写营业结束时间';
if (!TIME_RE.test(form.closeTime.trim())) return '营业结束时间格式须为 HH:MM';
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
return '营业结束时间须晚于开始时间';
}
if (form.dualHours) {
if (!form.openTime2.trim() || !form.closeTime2.trim()) return '请完整填写第二段营业时间';
if (!TIME_RE.test(form.openTime2.trim()) || !TIME_RE.test(form.closeTime2.trim())) {
return '第二段时间格式须为 HH:MM';
}
if (timeToMinutes(form.openTime2.trim()) >= timeToMinutes(form.closeTime2.trim())) {
return '第二段结束时间须晚于开始时间';
}
if (timeToMinutes(form.closeTime.trim()) >= timeToMinutes(form.openTime2.trim())) {
return '第二段开始时间须晚于第一段结束时间';
}
}
if (form.avgPrice.trim()) {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (!form.categoryId.trim()) 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().length > 1000) {
return '好客权益券使用规则最多 1000 字';
}
return null;
}
@@ -146,23 +221,29 @@ export function validateStoreStep2(
): string | null {
if (!form.coverUrl.trim()) return '请上传门头照';
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
if (envCount < 3) return '请上传至少 3 张环境照片';
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
if (!form.contractUrl.trim()) return '请上传签约合同';
return null;
}
export function patchEnvPhotoAt(urls: string[], index: number, url: string): string[] {
const envPhotoUrls = normalizeStringArray(urls, 3);
const envPhotoUrls = [...urls];
while (envPhotoUrls.length <= index) envPhotoUrls.push('');
envPhotoUrls[index] = url;
return envPhotoUrls;
}
export function validateStoreStep3(
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
form: Pick<
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
>,
): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
return null;
}
+26
View File
@@ -0,0 +1,26 @@
import { weixinSdk } from './weixin';
export type StorePosition = {
latitude: number;
longitude: number;
};
/** 获取当前 GPS 坐标(微信 JSSDK / 浏览器 Geolocation */
export async function locateStorePosition(): Promise<StorePosition> {
const outcome = await weixinSdk.getLocationDetailed();
if (!outcome.location) {
throw new Error(outcome.errMsg || '定位失败,请允许位置权限后重试');
}
const { latitude, longitude } = outcome.location;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error('定位结果无效');
}
return { latitude, longitude };
}
export function formatStoreCoords(lat?: string | number | null, lng?: string | number | null): string {
const a = lat != null && lat !== '' ? Number(lat) : NaN;
const b = lng != null && lng !== '' ? Number(lng) : NaN;
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
return `${a.toFixed(6)}, ${b.toFixed(6)}`;
}
+54
View File
@@ -0,0 +1,54 @@
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
export type PackageFormItem = StorePackageItemDto;
export function emptyPackage(index = 0): PackageFormItem {
return {
name: '',
price: '',
dishes: '',
usableTime: '',
otherNotes: '',
imageUrl: '',
sortOrder: index,
};
}
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: item.imageUrl?.trim() || '',
sortOrder: index,
}))
.filter(
(item) =>
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
);
}
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
const filled = normalizePackageFormItems(items);
if (filled.length > STORE_PACKAGE_MAX_COUNT) {
return `套餐最多 ${STORE_PACKAGE_MAX_COUNT}`;
}
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) return `${i + 1} 条套餐名称不能为空`;
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
}
return null;
}
export function formatPackagePrice(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return String(price);
return n % 1 === 0 ? String(n) : n.toFixed(2);
}
-8
View File
@@ -8,12 +8,4 @@ export function checkStorePhoneAvailable(phone: string) {
);
}
export function sendStorePhoneSms(phone: string) {
return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/stores/send-phone-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
}
export type { PartnerStorePhoneAvailableResponse };
+2 -2
View File
@@ -20,8 +20,8 @@ export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '时闭店';
if (s === 'CLOSED') return '关闭';
if (s === 'PAUSED') return '时闭店';
if (s === 'CLOSED') return '永久关闭';
return status;
}
+101
View File
@@ -0,0 +1,101 @@
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,
};
}
type LocPickerMessage = {
module?: string;
latlng?: { lat?: number; lng?: number };
poiaddress?: string;
poiname?: string;
cityname?: string;
};
const REFERER = 'dukang';
function coerceMessageData(data: unknown): LocPickerMessage | null {
if (data == null) return null;
if (typeof data === 'string') {
const trimmed = data.trim();
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return null;
try {
return JSON.parse(trimmed) as LocPickerMessage;
} catch {
return null;
}
}
if (typeof data === 'object') return data as LocPickerMessage;
return null;
}
/** 腾讯地图选点组件(iframe */
export function buildTencentLocPickerUrl(
key: string,
options?: { latitude?: number; longitude?: number },
): string {
const params = new URLSearchParams({
search: '1',
type: '1',
key,
referer: REFERER,
policy: '1',
total: '20',
radius: '2000',
mapdraggable: '1',
});
const lat = options?.latitude;
const lng = options?.longitude;
if (
lat != null &&
lng != null &&
Number.isFinite(lat) &&
Number.isFinite(lng) &&
!(lat === 0 && lng === 0)
) {
params.set('coord', `${lat},${lng}`);
params.set('coordtype', '5');
}
return `https://apis.map.qq.com/tools/locpicker?${params.toString()}`;
}
export function parseTencentLocPickerMessage(data: unknown): TencentPickedLocation | null {
const loc = coerceMessageData(data);
if (!loc || loc.module !== 'locationPicker') return null;
const lat = Number(loc.latlng?.lat);
const lng = Number(loc.latlng?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
let name = loc.poiname?.trim() || undefined;
const address = loc.poiaddress?.trim() || undefined;
if (name === '我的位置' && address) name = address;
return {
latitude: lat,
longitude: lng,
address,
name,
cityname: loc.cityname?.trim() || undefined,
};
}
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, request } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
const UPLOAD_TIMEOUT_MS = 120_000;
@@ -11,13 +16,23 @@ export type UploadFileResult = {
mock: boolean;
};
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
async function uploadFileToOssInner(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData();
formData.append('file', file);
formData.append('file', prepared);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPartnerPageView } from './analytics';
export function usePartnerPageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPartnerPageView(eventName, params);
}, [eventName, params]);
}
+17
View File
@@ -14,6 +14,8 @@ export type PartnerProfile = {
phone: string;
companyName?: string;
hasWechat?: boolean;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
isPrimary?: boolean;
};
@@ -46,6 +48,19 @@ export async function canPartnerUseWechatLogin(options: {
}
}
/** 短信登录/发码前:校验手机号对应合伙人存在且未暂停 */
export async function checkPartnerPhoneForLogin(phone: string): Promise<PartnerPhoneCheckResponse> {
const normalized = phone.trim();
if (!/^1\d{10}$/.test(normalized)) {
throw new Error('请输入正确的手机号');
}
return request<PartnerPhoneCheckResponse>('PARTNER_H5', '/partner/auth/phone/check', {
method: 'POST',
body: JSON.stringify({ phone: normalized }),
silent: true,
});
}
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
}
@@ -82,6 +97,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio
companyName: partner.companyName ? String(partner.companyName) : undefined,
isPrimary: partner.isPrimary !== false,
hasWechat: true,
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
}
: undefined,
};
+9
View File
@@ -5,7 +5,16 @@ import { getRouterBasename } from '@dukang/weixin-sdk';
import App from './App';
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
import { PartnerToastProvider } from './contexts/PartnerToastContext';
import { installClientErrorReporting } from '@dukang/client-logging';
import { apiBase } from './lib/api';
import './styles.css';
import './styles/legal.css';
installClientErrorReporting({
apiBase,
clientApp: 'PARTNER_H5',
getToken: () => localStorage.getItem('partnerAccessToken'),
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+205 -57
View File
@@ -1,42 +1,116 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function billStatusLabel(status: string) {
switch (status) {
case 'AWAITING_CONFIRM': return '待确认';
case 'UNPAID': return '未打款';
case 'PAID': return '已打款';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function canConfirm(status: string) {
return status === 'AWAITING_CONFIRM';
}
export default function BillsPage() {
usePartnerPageView('partner_bills_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
async function loadBills() {
setLoading(true);
try {
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
setBills(Array.isArray(list) ? list : []);
} catch {
setBills([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
void loadBills();
}, [navigate]);
const billId = searchParams.get('id');
const actionable = useMemo(
() => bills.filter((b) => canConfirm(b.status)),
[bills],
);
const bill = billId
? bills.find((b) => String(b.id) === billId) ?? bills[0]
: bills[0];
? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
: actionable[0] ?? bills[0];
function confirmBill() {
if (!confirmed) return;
const status = String(bill?.status || '');
const showApplyForm = !!bill && canConfirm(status);
const isUnpaid = status === 'UNPAID';
const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID';
function askConfirm(ids: string[]) {
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
return;
}
void doConfirm(ids);
}
async function doConfirm(ids: string[]) {
setSubmitting(true);
setTimeout(() => {
try {
if (ids.length === 1) {
await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
toastSuccess('已确认,等待总部打款');
setConfirmed(false);
setSelectedIds([]);
await loadBills();
} catch (e) {
toastError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
alert('申请已提交,请耐心等待打款审核。(preV1 演示)');
}, 1500);
}
}
function toggleSelect(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
return (
<div className="partner-bills-page">
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} />
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper">
<div className="partner-stepper-inner">
<div className="partner-stepper-line" aria-hidden>
<div className="partner-stepper-line-fill" style={{ width: '50%' }} />
<div
className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
/>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
@@ -45,17 +119,28 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--active">2</div>
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{showApplyForm && !confirmed ? '2' : (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
)}
</div>
<span className="partner-step-label partner-step-label--active"></span>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm">3</div>
<span className="partner-step-label"></span>
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
{isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'}
</div>
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'}
</span>
</div>
</div>
</div>
{!bill && <div className="empty"></div>}
{loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>}
{bill && (
<section className="partner-bill-card">
@@ -66,73 +151,136 @@ export default function BillsPage() {
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
</div>
<span className="partner-status-pill partner-status-pill--paused">{String(bill.status || '待确认')}</span>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</div>
{isRejected && bill.rejectReason && (
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}></p>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div>
</div>
)}
{isUnpaid && (
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
)}
<div className="partner-bill-amount">
<p className="text-muted body-md" style={{ marginBottom: 8 }}></p>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
<span className="amount-xl">{Number(bill.totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-info-row">
<span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.82).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
</div>
<div className="partner-info-row">
<span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.18).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
</div>
</div>
</div>
</section>
)}
{bills.length > 1 && (
{bills.length > 0 && (
<div style={{ padding: '0 20px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{bills.slice(1).map((b) => (
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{String(b.billNo)}</p>
<p className="label-md text-muted">{String(b.status)}</p>
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 className="headline-md" style={{ margin: 0 }}></h3>
{actionable.length > 1 && (
<button
type="button"
className="partner-fill-max"
style={{ fontSize: 13 }}
disabled={!selectedIds.length || submitting}
onClick={() => askConfirm(selectedIds)}
>
({selectedIds.length})
</button>
)}
</div>
{bills.map((b) => {
const id = String(b.id);
const selectable = canConfirm(b.status);
return (
<div
key={id}
className="partner-store-card"
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
{selectable ? (
<input
type="checkbox"
checked={selectedIds.includes(id)}
onChange={() => toggleSelect(id)}
style={{ marginTop: 4 }}
/>
) : (
<span style={{ width: 16 }} />
)}
<button
type="button"
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
) : null}
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div>
</button>
</div>
</div>
))}
);
})}
</div>
)}
<div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
{showApplyForm && (
<>
<div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
{bill && (
<footer className="partner-bill-footer">
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span></span>
</label>
<button
type="button"
className="partner-btn-primary"
disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={confirmBill}
>
{submitting ? '正在提交...' : '确认并申请打款'}
{!submitting && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
<footer className="partner-bill-footer">
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span></span>
</label>
<button
type="button"
className="partner-btn-primary"
disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={() => askConfirm([String(bill!.id)])}
>
{submitting ? '正在提交...' : '确认账单'}
{!submitting && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
</>
)}
</div>
</PullToRefresh>
);
}

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