129 Commits

Author SHA1 Message Date
jacy 984dec79ee Merge pull request 'Dev v3.4.17 版本发布' (#15) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/15
2026-08-16 12:31:16 +08:00
jacy f84614bbb4 Merge pull request 'v3.4.17版本发布' (#14) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/14
2026-08-16 12:30:21 +08:00
jacy c0ce494732 提交workbuddy记忆缓存
CI / verify (pull_request) Has been cancelled
2026-08-16 12:29:07 +08:00
jacy 69e201f927 fix(h5-partner): 门店套餐编辑按钮改为紧凑型并贴右对齐
CI / verify (push) Has been cancelled
2026-08-16 12:22:54 +08:00
jacy ecd9973d8f feat(admin): 套餐变更对比逐字差异定位 + 开发计划并入v3.4.17
CI / verify (push) Has been cancelled
2026-08-16 12:17:34 +08:00
jacy bbd7e52e70 feat(admin): 套餐变更对比逐字段列出变动明细(v3.4.18)
CI / verify (push) Has been cancelled
2026-08-16 12:09:37 +08:00
jacy 1c91e6bae9 v3.4.17 门店套餐审核快捷入口
CI / verify (push) Has been cancelled
2026-08-16 11:57:01 +08:00
jacy 029fd690a5 merge(dev): fix(admin) 套餐审核导航样式恢复一致
CI / verify (push) Has been cancelled
2026-08-16 08:56:41 +08:00
jacy 0bfc19b1a4 merge(dev_jacy): fix(admin) 套餐审核导航样式恢复一致 2026-08-16 08:56:28 +08:00
jacy bafb342600 fix(admin): 套餐审核导航恢复与子菜单一致的样式
- 移除对「套餐审核」文字的强制白色处理,文字随菜单主题正常继承 normal/hover/selected 颜色
- 徽章仅作为待审数量提示图标,pendingCount>0 时才显示,不参与文字着色

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:53:31 +08:00
232 changed files with 12931 additions and 8935 deletions
+3
View File
@@ -39,6 +39,9 @@ src/
## UI 约束 ## UI 约束
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
- C 端订单 **5 Tab**(含 pending_ship - C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态 - 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md` - 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
@@ -0,0 +1,46 @@
---
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
alwaysApply: false
---
# mini-user · 微信 open-type 硬规则
## 禁止(必踩坑)
**现象**`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
```tsx
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
<View className="mask" onClick={close}>
<View className="sheet" onClick={(e) => e.stopPropagation()}>
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
</View>
</View>
```
## 正确写法
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
```tsx
// ✅
<View className="mask">
<View className="backdrop" onClick={close} />
<View className="sheet">
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
...
</Button>
</View>
</View>
```
## 附加
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
@@ -13,7 +13,7 @@
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。 > **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
Vite 代理:`/api``localhost:3000` Vite 代理:`/api``localhost:3010`(可用 `VITE_API_TARGET` 覆盖)
## 共享包 ## 共享包
+45
View File
@@ -0,0 +1,45 @@
# 2026-08-12 工作记录
## 杜康好客 · 三项功能需求落地(dev_jacy 分支)
### 需求1:管理端用户列表增加好客权益金额列
- 后端 `server/dukang-api/src/modules/ops/admin-users.service.ts`
- 新增常量 `BENEFIT_STAT_STATUSES = ['ACTIVE','USED_UP']``UserBenefitStat` 类型、`EMPTY_BENEFIT_STAT``toAmount()`
- `list()` / `detail()` 均调用新增私有方法 `loadBenefitStats(userIds)`,用 `benefitCoupon.groupBy({ by:['userId'], where:{status:{in:[...BENEFIT_STAT_STATUSES]}}, _sum:{totalAmount,usedAmount,balance} })` 批量聚合,避免 N+1。
- `mapAdminUserRow` 增加 `benefit` 参数并展开到返回对象。统计口径与 C 端 `listCoupons` 一致(排除 VOID)。
- 前端 `apps/admin-web/src/lib/api.ts``AdminUserRow` 增加 `benefitTotalAmount?/benefitUsedAmount?/benefitBalance?: number`
- 前端 `apps/admin-web/src/pages/UsersPage.tsx`:新增「剩余权益/已用权益/累计权益」三列(右对齐、可排序,scroll x 改为 1830),详情抽屉 Descriptions 增加「好客权益」项。
### 需求2:管理端门店入驻合同支持多图上传
- 新建共享工具 `server/dukang-api/src/common/store-media/contract-urls.util.ts``MAX_CONTRACT_FILES=20``normalizeContractUrls(urls?, legacy?)``contractMediaType(url)`.pdf→FILE 否则 IMAGE)。
- 后端 `admin-stores.service.ts`import 上述工具;create645 行)/ update451 行)路径由单条 `contractUrl` 改为遍历建立多条 `bizType:'CONTRACT'` 记录(`sortOrder:i``mediaType` 按扩展名判定)。
- DTO `admin-mutate.dto.ts``contractUrl?:string` 标记 `@deprecated`,新增 `contractUrls?:string[]``@IsArray @IsString({each:true}) @ArrayMaxSize(20)`)。
- 前端 `apps/admin-web/src/lib/storeCreate.ts``StoreCreateForm.contractUrl?:string``contractUrls?:string[]`
- `MultiImageUpload.tsx`:新增 `isPdf()``FilePdfOutlined`、PDF 缩略图卡片、`buttonText` prop。
- `StoresPage.tsx`:编辑抽屉 + 创建 step1 表单均改用 `MultiImageUpload`name=contractUrls, maxCount=20, accept=image/*,.pdf, buttonText="批量上传合同"),回显/保存 payload 改为 `contractUrls:string[]`
### 需求3:合伙人 H5 门店入驻合同支持多图上传
## 发布流水线(同一会话,22:45 后)
完整跑通 dukang-release 标准流程:`dev_jacy` → push → merge `dev` → merge `main` → 发生产。
- 提交 `8805335` feat(ops,store,partner): 用户权益列 + 门店合同多图上传(13 文件,356+/59-
- `dev_jacy` push → `origin/dev_jacy`(8805335)
- merge `dev_jacy``dev``491f03e`push `origin/dev`
- merge `dev``main``4a0e184`push `origin/main`
- `deploy/deploy-prod.sh -- --skip-db`(无 Prisma schema 变更,故 --skip-db)→ `DEPLOY_EXIT=0``==> 发版完成 (production)`
- 生产 PM2 全 online`dukang-api`(8090) / admin-web / h5-partner / h5-shop / h5-user;健康检查 `api:200` `mini-user(h5):200`;版本记录 commit=4a0e184
### 踩坑/注意
- `git checkout main` 时本机工作树出现 `server/dukang-api` 大量 `D`(误删)假象:实为 sandbox 下 checkout 未刷新工作树,非仓库损坏。用 `git checkout -f main` 重新填充即恢复;`origin/main` 始终正确(ls-remote 核对 4a0e184)。
- 提交/合并后 `git status` 报 "ahead of origin by N" 为本地 tracking ref 陈旧,非真实分歧(ls-remote 已核对远程三分支均含本次提交)。
- 仅提交 13 个需求文件 + 新建 `server/dukang-api/src/common/store-media/`,已排除 `.workbuddy/``fix_acl.ps1`
- 发布命令在 sandbox 下直接运行 SSH 部署成功(未强制禁用沙箱)。
- `apps/h5-partner/src/lib/storeDraft.ts``StoreDraftForm.contractUrl:string``contractUrls:string[]``defaultStoreForm` 改为 `contractUrls:[]`;新增 `MAX_CONTRACT_COUNT=20``normalizeStoreDraftForm` 新增 `contractUrls` 计算(兼容旧 `contractUrl` 迁移为数组,去重限 20);`validateStoreStep2` 参数/校验改为 `contractUrls`,不足 1 张返回「请上传签约合同」。
- `MultiOssUploadField.tsx`:新增 `accept?``unit?` props`isPdf()`PDF 缩略图卡片。
- `StoreCreatePage.tsx`:签约合同区块 `OssUploadField``MultiOssUploadField`value=`form.contractUrls ?? []`, maxCount=MAX_CONTRACT_COUNT, unit="个", accept="image/*,.pdf");提交体 `contractUrl``contractUrls`,新增 `const contractUrls = Array.from(new Set(...)).slice(0, MAX_CONTRACT_COUNT)`envPhotoUrls slice 上限由 3 改为 20。修正了渲染块误用未定义变量 `contractUrls`(已改为从 form 读取)。
### 验证
- 后端 `npx tsc --noEmit`server/dukang-api)通过,exit 0。
- admin-web / h5-partner 使用 `git stash` 基线对比法:本次改动**未引入任何新类型错误**(现存错误均为预存在基线问题:css module 声明、`import.meta.env`、过期 config 类型、TabLayout `end`、staff `staffRole` 等)。
- 向后兼容:旧 `contractUrl` 字段保留并标记 deprecated,H5 草稿旧数据自动迁移;避免破坏现有 API 客户端。
+79
View File
@@ -0,0 +1,79 @@
# 2026-08-16
## 新增:线上库同步本地 CLI 工具
- 文件:`deploy/sync-prod-db-to-local.sh`(新建,未提交)
- 用途:将生产库 `dukang_prod`(阿里云 RDS `rm-bp139andp1aa385i9.mysql.rds.aliyuncs.com:3306`,用户 `dukangadmin`)同步到本地 Docker MySQL `dukang_haoke`localhost:6016 / 容器内 `dukang-v1-mysql`root/root)。
- 原理:SSH 隧道借道 `dukang-server``-L 6018:rds:3306`)→ 本地无 mysql 客户端,故在 `dukang-v1-mysql` 容器内跑 mysqldump/mysql,经 `host.docker.internal:TUNNEL_PORT` 连隧道;管道直导,不落临时文件。
- 安全:默认先 `docker exec mysqldump` 备份本地到 `deploy/backups/`;需 `--yes` 跳过交互确认;`--dry-run` 只读远端 DATABASE_URL 并打印计划。
- 远端凭据从 `/opt/dukang/server/dukang-api/.env.production` 的 DATABASE_URL 运行时解析(密码经 `MYSQL_PWD` 环境变量传入,避免命令行暴露)。
- 已知限制:不支持密码含 `@`prod DB 名与本地不同(dukang_prod→dukang_haoke)靠 mysqldump 不带 `--databases` 实现库名映射。
- 状态:仅跑通 `--dry-run`,未执行真实同步(待用户确认,因会覆盖本地库并拉取生产数据)。
## 修复:首次同步失败的根因
- 失败现象:`mysqldump` 退出码 2,提示 `本地库可能处于不一致状态`(工具自身的失败兜底提示)。
- 真实根因有两条:
1. **密码解析错误**:生产 DB 密码含 `@` 字符(`passHasAt=true`)。原 bash 解析用「第一个 @」切分 userinfo/host,把密码里 `@` 之后的部分截断,导致 Access denied。
修复:改为在服务端用 `new URL()` 解析,base64 回传各字段(user/host/port/db/password),本地 `base64 -d` 还原,彻底免疫 `@`/`:`/`$` 等特殊字符。服务端 auth 测试 `authTestStatus=0` 验证密码正确。
2. **权限不足**`dukangadmin`(应用库用户)无 `RELOAD`/`FLUSH_TABLES` 权限。`--single-transaction` 仍会触发全局 `FLUSH TABLES` → Access denied。
修复:`mysqldump` 改用 `--lock-tables=0`(不发起 FTWRL+ `--set-gtid-purged=OFF`(RDS 启用 GTID,否则本地恢复会报 GTID_PURGED 错误),并保留 `--skip-routines --skip-events --column-statistics=0 --no-tablespaces --add-drop-table --skip-triggers --no-create-db`
- 验证:结构导出(--no-data)在 4 组 flag 中仅 `--lock-tables=0` / `--skip-lock-tables`(不含 --single-transaction)退出 0。
- 现工具已修正并实际执行同步(--yes,先自动备份本地到 deploy/backups/)。
- 注意:`--lock-tables=0` 无事务一致性快照;生产库在线写入期间导出可能有极小不一致,但恢复时 mysqldump 自带 `FOREIGN_KEY_CHECKS=0`,_dev 库可接受。若需完全一致快照,可给 dukangadmin 授 RELOAD 后用 --single-transaction。
## 修改:套餐审核导航样式恢复一致
- 文件:`apps/admin-web/src/layouts/AdminLayout.tsx``attachPackageAuditBadge``/store-package-audits` 项)。
- 诉求演变:用户先要求「文字恒为白色」→ 后改为「不要单独处理,保持与子菜单一致,只是多一个待审徽章图标」。
- 最终实现:label 改为 `<span style={{display:'inline-flex',gap:6}}>套餐审核{pendingCount>0 && <Badge count size="small"/>}</span>`。文字无强制颜色,随菜单主题继承 normal/hover/selected 颜色;徽章仅 pendingCount>0 时作为图标提示,不参与着色。
## 发布流水线(本次收尾,含之前未上线的 8+1 文件)
- 背景:此前「联系电话脱敏+套餐多行输入+同步工具」等 9 文件 commit `8de4179` 已 push dev_jacy/dev/main,但**生产发布失败**deploy.env 丢失导致 `未配置 DEPLOY_HOST`)。
- 修复发布:`deploy.env` 是 gitignore 的本地密钥文件,已丢失不可找回;改用 SSH 别名 `dukang-server`~/.ssh/configHostName 47.110.129.57 / User root / IdentityFile pem)覆盖:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`
- 关键排查:`origin/*` 本地缓存 ref 多次陈旧(误报 ahead/behind),必须以 `git ls-remote` 为准。本次真实远端:dev_jacy=8de4179、dev=c05cd0e、main=bac5c7a,与本地一致,无丢失。
- 本次提交:`bafb342` fix(admin) 套餐审核样式 → push dev_jacy → merge dev(`0bfc19b`) → merge main(`029fd69`) → push。
- 发布结果:`DEPLOY_EXIT=0``==> 发版完成 (production)`,健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `029fd69`
- 提示:以后若想恢复标准 `bash deploy/deploy-prod.sh`(不带 --host),需重建 `deploy/deploy.env`(从 `deploy.env.example` 复制,填入 DEPLOY_HOST=47.110.129.57 等;该文件 gitignore,勿提交)。
## 排查:h5-partner 本地 /login 调 /partner/me 报 401
- 现象:`http://localhost:5176/login` 启动后控制台报 `GET /api/v1/partner/me 401 (Unauthorized)`
- 结论:**不是 bug,是登录页的「过期 token 探测」**。链路:Vite 代理 `/api``localhost:3010`(正常);后端返回 `{"code":401,"message":"Missing token"}`(说明后端在线、代理通)。`ensureSession()`api.ts:244)只在 localStorage 有 token 时才打 `/partner/me`;无 token 直接返回未登录。所以 401 来自之前测试残留的失效 token → 前端静默清除并展示登录表单,不崩、不弹错。刷新页面(token 已清)即不再出现。
- 本地登录方式(关键,README 已过期):
- `MOCK_SMS=true`(.env),短信走 mock,不连运营商;固定码 `MOCK_SMS_FIXED_CODE` 默认 `999888`README 写的 123456 已不准)。
- README 的测试号 `13700000001` **不在本地同步库**;本地 `partner_account` 共 9 个,可用真实号如 `18049821889`(Jacy) / `13073729990`(queen)(均 ACTIVE)。
- 流程:填真实合伙人手机号 → 获取验证码 → **验证码打印在 `pnpm dev:api` 后端终端**`Mock SMS → 180****8889 code=XXXXXX`)→ 填码登录。
- 注意 h5-partner 默认 5175,本次因 5175 被占自动落到 5176,代理是路径匹配不受影响。
## 发布:v3.4.17 门店套餐审核快捷入口 上生产
- 提交 `1c91e6b`dev_jacy 上,含后端 admin-stores.service.ts + AdminStorePackagesSection/StorePackageAuditsPage/StoresPage 三前端 + 开发计划文档)。
- 流水线:直接快进推送(避免 git checkout 工作树不刷新):`git push origin dev_jacy` + `dev_jacy:dev` + `dev_jacy:main`,三分支统一到 `1c91e6b`;本地 `git branch -f dev/main origin/*` 同步。
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(后端仅给列表响应加字段,无 schema 变更,故 skip-db)。
- 结果:`DEPLOY_EXIT=0`,新 tag `v3.4.17`,全部应用构建通过,PM2 重启 dukang-api/h5-shop/h5-partner/admin-web(另拉起 h5-user),健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `1c91e6b`
## 发布:v3.4.18 套餐变更对比逐字段列出变动明细
- 需求:套餐变更对比「将变动的地方详细列出来」(之前只给「新增/删除/变更/未变」粗粒度标记,不列具体字段)。
- 改动仅在 `apps/admin-web/src/pages/StorePackageAuditsPage.tsx`
- 新增 `fieldChanges(live, proposed)`:逐字段比对 价格/套餐名称/菜品内容/可用时间/其他说明/图片,产出 `label+旧值→新值` 明细。
- `diffPackages` 给「变更」行附 `changes`
- `PackageDetailCard` 在「待审核」卡片底部渲染「变更明细」块(旧值删除线 → 新值加粗,橙色块)。
- 抽屉顶部加「新增 X · 删除 Y · 变更 Z · 未变 W」汇总条。
- 提交 `bbd7e52`feat(admin) 套餐变更对比逐字段列出变动明细),快进推送 dev_jacy/dev/main。
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(无 schema 变更)。
- 结果:构建通过、PM2 重启 admin-web 等、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `bbd7e52`(无精确 tag,记 tag=-,无害)。
- 已知局限:配对按「套餐名称」(空则索引) 位置匹配;若套餐被**改名**,会误判为「删除旧名+新增新名」而非「变更」,故名称变更不会出现在逐字段明细里。
## 优化:套餐变更对比·逐字差异 + 文档并入 v3.4.17
- 需求:变动明细要能**定位到文本中具体哪些字变了(逐字检查)**,且把开发计划文档合并到 v3.4.17 文档里(不再单独留 v3.4.18 文档)。
- 代码改动(仍仅 `StorePackageAuditsPage.tsx`):
- `diffText(a,b)`LCS 动态规划 (`dp[i][j]`)+回溯,产出 `equal/delete/insert` 段落并合并相邻同类型;`O(|a|·|b|)`,字段长度可控。
- `TextDiff({oldText,newText})`:两行渲染——「原:」红删线标被删字、「新:」绿标新增字。
- `FieldChange` 增加 `kind: 'text'|'value'`:价格/图片走 `旧→新`;套餐名称/菜品内容/可用时间/其他说明走 `TextDiff` 逐字高亮。
- `杜康好客-v3.4.17-门店套餐审核快捷入口.md` 新增第 F 节「套餐变更对比·逐字段/逐字明细」,并把原先 v3.4.18 的字段级说明并入,删去独立 v3.4.18 文档计划。
- 提交 `ecd9973`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`
- 结果:构建通过、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `ecd9973`tag=-,无害)。
- 验证要点:开任一「变更」套餐的待审核卡片 → 底部「变更明细」→ 菜品/说明等文本以红删+绿增逐字呈现。
## 优化:h5-partner 门店详情·门店套餐「编辑套餐」按钮
- 需求:按钮**缩小、靠最右侧、保持美观**。根因:`.partner-btn-outline` 是整行大按钮(`flex:1; height:48px; font-size:16px`),放在卡片标题右侧会被拉伸成大块。
- 改动:`apps/h5-partner/src/styles.css` 新增 `.partner-btn-outline--compact``flex:none; height:30px; padding:0 14px; font-size:13px; font-weight:500; border-radius:var(--radius-sm); color:var(--color-primary)`hover 用 `color-mix` 8% 底色);`StoreDetailPage.tsx:288` 的「编辑套餐」按钮加该类(父级 `space-between` 已保证贴右)。
- 提交 `69e201f`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`
- 结果:构建通过、PM2 重启 h5-partner、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `69e201f`tag=-,无害)。
- 注意:`StoreDetailPage.tsx:311` 有个历史遗留 TS 错误(`status === 'CLOSED'` 类型无重叠),与本改动无关,未动。
+6 -3
View File
@@ -11,7 +11,7 @@
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) | | **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 | | **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 | | **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** | | ~~V2 / preV1~~ | 历史参考**已压缩**V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。 **禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
@@ -28,7 +28,7 @@ pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端) # 开发(分终端)
pnpm dev:api # http://localhost:3000/api/v1 pnpm dev:api # http://localhost:3010/api/v1
pnpm dev:user # :5173 pnpm dev:user # :5173
pnpm dev:shop # :5174 pnpm dev:shop # :5174
pnpm dev:partner # :5175 pnpm dev:partner # :5175
@@ -137,11 +137,14 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成 订单 Tab:待付款 | 已付款 | 已完成
``` ```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
## 环境与发版 ## 环境与发版
| 环境 | 分支 | 目录 | 端口 | 域名 | | 环境 | 分支 | 目录 | 端口 | 域名 |
|------|------|------|------|------| |------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — | | local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` | | **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` | | **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
+1
View File
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
- 禁止 import `server/` 或另一个 `apps/*` 的源码 - 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui` - UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed` - C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed`
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
## 新页面 workflow ## 新页面 workflow
+2
View File
@@ -10,7 +10,9 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.5.1", "@ant-design/icons": "^5.5.1",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*", "@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"antd": "^5.22.0", "antd": "^5.22.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"echarts": "^6.1.0", "echarts": "^6.1.0",
+2
View File
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage'; import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage'; import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage'; import SystemSettingsPage from './pages/SystemSettingsPage';
import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage'; import WecomBotsPage from './pages/WecomBotsPage';
import WecomMessagePushesPage from './pages/WecomMessagePushesPage'; import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage'; import WecomBotLogsPage from './pages/WecomBotLogsPage';
@@ -136,6 +137,7 @@ export default function App() {
<Route path="/deliveries" element={<DeliveriesPage />} /> <Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} /> <Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} /> <Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} /> <Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} /> <Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route> </Route>
+18
View File
@@ -86,3 +86,21 @@ body,
.admin-table-nowrap .ant-table-cell-ellipsis { .admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap; white-space: nowrap;
} }
.admin-package-audit-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.admin-package-audit-col {
min-width: 0;
}
.admin-package-audit-text {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
max-width: none;
}
@@ -1,217 +1,381 @@
import { useEffect, useState } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Alert, Button, Form, Input, InputNumber, Space, Typography, message } from 'antd'; import { useNavigate } from 'react-router-dom';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons'; import { DownOutlined, UpOutlined } from '@ant-design/icons';
import type { StorePackageItemDto } from '@dukang/shared-types'; import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types'; import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto; type PackageRow = StorePackageItemDto;
export type AdminStorePackagesHandle = {
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
};
function emptyRow(index = 0): PackageRow { function emptyRow(index = 0): PackageRow {
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index }; return {
name: '',
price: '0',
dishes: '',
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
} }
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) { const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
const [items, setItems] = useState<PackageRow[]>([emptyRow()]); function AdminStorePackagesSection({ storeId }, ref) {
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({}); const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [loading, setLoading] = useState(true); const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
const itemsRef = useRef(items);
const loadingRef = useRef(loading);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
setLoading(true); itemsRef.current = items;
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`) }, [items]);
.then((data) => {
useEffect(() => {
loadingRef.current = loading;
}, [loading]);
useEffect(() => {
setLoading(true);
setPendingRequest(null);
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => {
setPendingRequest(data.pendingRequest ?? null);
setItems(
data.live?.length
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false));
}, [storeId]);
// 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
useEffect(() => {
const onChanged = () => {
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => setPendingRequest(data.pendingRequest ?? null))
.catch(() => undefined);
};
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
}, [storeId]);
function goAudit() {
if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`);
}
const pendingReminder =
pendingRequest && pendingRequest.status === 'PENDING' ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="该门店有待审核套餐"
description={
<Space>
<Button size="small" type="primary" onClick={goAudit}>
</Button>
<Button size="small" onClick={goAudit}>
</Button>
</Space>
}
/>
) : null;
function updateAt(index: number, patch: Partial<PackageRow>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addRow() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
setItems((prev) => [...prev, emptyRow(prev.length)]);
}
function removeAt(index: number) {
const run = () => {
setItems((prev) => {
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
return next.length ? next : [];
});
setCollapsed((prev) => {
const next: Record<number, boolean> = {};
Object.entries(prev).forEach(([k, v]) => {
const i = Number(k);
if (i < index) next[i] = v;
else if (i > index) next[i - 1] = v;
});
return next;
});
};
if (items.length === 1) {
Modal.confirm({
title: '清空门店套餐',
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
okText: '确认删除',
cancelText: '取消',
onOk: run,
});
return;
}
run();
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
async function save(opts?: { quiet?: boolean }) {
const currentItems = itemsRef.current;
const filled = currentItems
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
imageUrl: imageUrls[0] ?? null,
imageUrls,
sortOrder: index,
};
})
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
.filter((item) => {
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
const hasImages = item.imageUrls.length > 0;
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
return hasText || hasImages || hasNonZeroPrice;
});
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
throw new Error('套餐校验失败');
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
throw new Error('套餐校验失败');
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
throw new Error('套餐校验失败');
}
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`);
throw new Error('套餐校验失败');
}
}
setSaving(true);
try {
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
method: 'PUT',
body: JSON.stringify({
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
if (!opts?.quiet) message.success('套餐已保存并生效');
setItems( setItems(
data.live?.length data.live?.length
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })) ? data.live.map((p, i) => {
: [emptyRow()], const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
); );
}) } catch (e) {
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败')) if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
.finally(() => setLoading(false)); throw e;
}, [storeId]); } finally {
setSaving(false);
function updateAt(index: number, patch: Partial<PackageRow>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addRow() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
setItems((prev) => [...prev, emptyRow(prev.length)]);
}
function removeAt(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
setCollapsed((prev) => {
const next: Record<number, boolean> = {};
Object.entries(prev).forEach(([k, v]) => {
const i = Number(k);
if (i < index) next[i] = v;
else if (i > index) next[i - 1] = v;
});
return next;
});
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
async function save() {
const filled = items
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
sortOrder: index,
}))
.filter((item) => item.name || item.dishes || item.price);
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
return;
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
return;
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
} }
} }
setSaving(true); useImperativeHandle(ref, () => ({
try { saveIfLoaded: async (opts) => {
await request(`/admin/stores/${storeId}/packages`, { if (loadingRef.current) return { skipped: true };
method: 'PUT', await save(opts);
body: JSON.stringify({ return { skipped: false };
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })), },
}), }));
});
message.success('套餐已保存并生效'); if (loading) {
} catch (e) { return <Typography.Text type="secondary"></Typography.Text>;
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
} }
}
if (loading) { if (!items.length) {
return <Typography.Text type="secondary"></Typography.Text>; return (
} <Form layout="vertical" requiredMark={false}>
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
{pendingReminder}
<Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button>
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button>
</Form>
);
}
return ( return (
<Form layout="vertical" requiredMark={false}> <Form layout="vertical" requiredMark={false}>
<Alert {pendingReminder}
type="info" <Alert
showIcon type="info"
style={{ marginBottom: 16 }} showIcon
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`} style={{ marginBottom: 16 }}
/> message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
/>
{items.map((item, index) => { {items.map((item, index) => {
const isCollapsed = !!collapsed[index]; const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`; const displayName = item.name.trim() || `套餐 ${index + 1}`;
return ( return (
<div <div
key={index} key={index}
style={{ style={{
marginBottom: 16, marginBottom: 16,
padding: 16, padding: 16,
border: '1px solid #f0f0f0', border: '1px solid #f0f0f0',
borderRadius: 8, borderRadius: 8,
background: '#fafafa', background: '#fafafa',
}} }}
> >
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}> <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
<Button <Button
type="text" type="text"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />} icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => toggleCollapse(index)} onClick={() => toggleCollapse(index)}
style={{ paddingLeft: 0, height: 'auto' }} style={{ paddingLeft: 0, height: 'auto' }}
> >
<Typography.Title level={5} style={{ margin: 0 }}> <Typography.Title level={5} style={{ margin: 0 }}>
{displayName} {displayName}
</Typography.Title> </Typography.Title>
</Button>
{items.length > 1 ? (
<Button type="link" danger onClick={() => removeAt(index)}>
</Button> </Button>
{items.length > 0 ? (
<Button type="link" danger onClick={() => removeAt(index)}>
</Button>
) : null}
</Space>
{!isCollapsed ? (
<>
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
<Input
placeholder="如:套餐A"
value={item.name}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</Form.Item>
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonAfter="元"
placeholder="198"
value={item.price === '' ? undefined : Number(item.price)}
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
/>
</Form.Item>
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
<Input.TextArea
rows={2}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
/>
</Form.Item>
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
<Input.TextArea
rows={2}
placeholder="节假日除外"
value={item.usableTime || ''}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</Form.Item>
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
<PackageImagesUpload
value={normalizeStorePackageImageUrls(item)}
onChange={(imageUrls) =>
updateAt(index, {
imageUrls,
imageUrl: imageUrls[0] ?? '',
})
}
/>
</Form.Item>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
<Input.TextArea
rows={2}
placeholder="不可叠加"
value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</Form.Item>
</>
) : null} ) : null}
</Space> </div>
);
})}
{!isCollapsed ? ( {items.length < STORE_PACKAGE_MAX_COUNT ? (
<> <Button onClick={addRow} style={{ marginBottom: 16 }}>
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
<Input </Button>
placeholder="如:套餐A" ) : null}
value={item.name}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</Form.Item>
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
<InputNumber
min={0} </Typography.Paragraph>
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 }}> <Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
<Input.TextArea
rows={2}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
/>
</Form.Item>
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
<Input
placeholder="节假日除外"
value={item.usableTime || ''}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</Form.Item>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
<Input
placeholder="不可叠加"
value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</Form.Item>
</>
) : null}
</div>
);
})}
{items.length < STORE_PACKAGE_MAX_COUNT ? (
<Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button> </Button>
) : null} </Form>
);
},
);
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}> export default AdminStorePackagesSection;
C
</Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
</Form>
);
}
@@ -1,6 +1,5 @@
import { Button, Form, Space, Typography } from 'antd'; import { Typography } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import MultiImageUpload from './MultiImageUpload';
import OssUpload from './OssUpload';
type Props = { type Props = {
name?: string; name?: string;
@@ -8,42 +7,35 @@ type Props = {
bizType?: string; bizType?: string;
/** 最多可添加张数;不传则不限制 */ /** 最多可添加张数;不传则不限制 */
maxCount?: number; maxCount?: number;
/** Form.Item 注入 */
value?: string[];
onChange?: (urls: string[]) => void;
}; };
/**
* 商品详情/轮播等多图列表。
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
*/
export default function DetailImageUrlList({ export default function DetailImageUrlList({
name = 'detailImageUrls',
label, label,
bizType = 'DETAIL', bizType = 'DETAIL',
maxCount, maxCount,
value,
onChange,
}: Props) { }: Props) {
return ( return (
<> <>
{maxCount != null && ( <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> {label}{maxCount != null ? `,最多 ${maxCount}` : ''}
{maxCount} {label} </Typography.Text>
</Typography.Text> <MultiImageUpload
)} bizType={bizType}
<Form.List name={name}> mediaType="IMAGE"
{(fields, { add, remove }) => ( maxCount={maxCount}
<> value={value}
{fields.map((field) => ( onChange={onChange}
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}> tip={`支持一次选择多张${label}`}
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}> />
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
{(!maxCount || fields.length < maxCount) && (
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
)}
</>
)}
</Form.List>
</> </>
); );
} }
@@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Image, Space, Typography, Upload, message } from 'antd';
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
bizType: string;
mediaType?: OssMediaType;
/** 最多张数;不传则不限制 */
maxCount?: number;
tip?: string;
accept?: string;
/** 上传按钮文案,默认「批量上传图片」 */
buttonText?: string;
};
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
function isPdf(url: string) {
return /\.pdf(\?|$)/i.test(url);
}
/**
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
* Form.Item 直接绑定 string[]。
*/
export default function MultiImageUpload({
value,
onChange,
bizType,
mediaType = 'IMAGE',
maxCount,
tip,
accept = 'image/*',
buttonText = '批量上传图片',
}: Props) {
const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
const onChangeRef = useRef(onChange);
const [uploading, setUploading] = useState(false);
const batchBuf = useRef<File[]>([]);
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const uploadChain = useRef(Promise.resolve());
useEffect(() => {
urlsRef.current = urls;
}, [urls]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
return () => {
if (batchTimer.current) clearTimeout(batchTimer.current);
};
}, []);
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
const canAdd = remaining > 0;
async function uploadBatch(files: File[]) {
const current = urlsRef.current;
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
const picked = files.slice(0, room);
if (!picked.length) {
message.warning(maxCount != null ? `最多 ${maxCount}` : '无法上传');
return;
}
if (files.length > picked.length) {
message.warning(`已达上限,仅上传前 ${picked.length}`);
}
setUploading(true);
const appended: string[] = [];
let fail = 0;
try {
for (const file of picked) {
try {
const result = await uploadFileToOss(file, { bizType, mediaType });
appended.push(result.url);
} catch {
fail += 1;
}
}
if (appended.length) {
// 始终基于最新列表追加,避免并行上传互相覆盖
const next = [...urlsRef.current, ...appended];
urlsRef.current = next;
onChangeRef.current?.(next);
message.success(`成功上传 ${appended.length}${fail ? `,失败 ${fail}` : ''}`);
} else if (fail) {
message.error('上传失败');
}
} finally {
setUploading(false);
}
}
function enqueueUploadBatch(files: File[]) {
uploadChain.current = uploadChain.current
.then(() => uploadBatch(files))
.catch(() => undefined);
}
function flushBatch() {
if (batchTimer.current) {
clearTimeout(batchTimer.current);
batchTimer.current = null;
}
if (!batchBuf.current.length) return;
const files = [...batchBuf.current];
batchBuf.current = [];
enqueueUploadBatch(files);
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
batchBuf.current.push(file as File);
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
if (batchTimer.current) clearTimeout(batchTimer.current);
batchTimer.current = setTimeout(() => {
flushBatch();
}, 80);
return false;
};
function removeAt(index: number) {
const next = urlsRef.current.filter((_, i) => i !== index);
urlsRef.current = next;
onChangeRef.current?.(next);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{tip ??
(maxCount != null
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
: '支持一次选择多张批量上传')}
{maxCount != null ? `(已选 ${urls.length}/${maxCount}` : urls.length ? `(已选 ${urls.length}` : ''}
</Typography.Text>
{urls.length > 0 ? (
<Image.PreviewGroup>
<Space wrap size={12}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: 96,
height: 96,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 4,
borderRadius: 6,
border: '1px solid #f0f0f0',
background: '#fafafa',
fontSize: 12,
}}
>
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
<span>PDF</span>
</a>
) : (
<Image
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
)}
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeAt(index)}
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'rgba(255,255,255,0.85)',
}}
/>
</div>
))}
</Space>
</Image.PreviewGroup>
) : null}
<Upload
accept={accept}
multiple
showUploadList={false}
beforeUpload={beforeUpload}
disabled={uploading || !canAdd}
>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
{canAdd ? buttonText : '已达上限'}
</Button>
</Upload>
</Space>
);
}
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Empty,
Image,
Space,
Spin,
Timeline,
Typography,
message,
} from 'antd';
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
import { isXfxProviderCode } from '@dukang/shared-types';
import { request } from '../lib/api';
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
type Props = {
open: boolean;
orderId: string | null;
orderNo?: string | null;
orderStatus?: string | null;
onClose: () => void;
};
function providerLabel(provider?: string | null, company?: string | null) {
if (company) return company;
if (!provider) return '—';
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
if (isXfxProviderCode(provider)) return '小飞侠';
return provider;
}
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
return [...nodes].sort((a, b) => {
const ta = new Date(a.createTime).getTime();
const tb = new Date(b.createTime).getTime();
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
});
}
export default function OrderTrackDrawer({
open,
orderId,
orderNo,
orderStatus,
onClose,
}: Props) {
const [loading, setLoading] = useState(false);
const [track, setTrack] = useState<OrderTrackDto | null>(null);
const load = useCallback(async () => {
if (!orderId) return;
setLoading(true);
try {
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
setTrack(data);
} catch (e) {
setTrack(null);
message.error(e instanceof Error ? e.message : '加载路由失败');
} finally {
setLoading(false);
}
}, [orderId]);
useEffect(() => {
if (!open || !orderId) {
setTrack(null);
return;
}
void load();
}, [open, orderId, load]);
const nodes = useMemo(
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
[track?.nodes],
);
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
orderStatus || '',
);
return (
<Drawer
title="物流路由"
width={520}
open={open}
onClose={onClose}
extra={
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
</Button>
}
>
<Spin spinning={loading}>
{(orderNo || track?.trackingNo || track?.provider) && (
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
<Descriptions.Item label="配送方式">
{providerLabel(track?.provider, track?.logisticsCompany)}
</Descriptions.Item>
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
{track?.manualQueryUrl ? (
<Descriptions.Item label="物流查询">
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
</a>
</Descriptions.Item>
) : null}
{track?.estimatedArrival?.arriveTime ? (
<Descriptions.Item label="预计送达">
{fmtTime(track.estimatedArrival.arriveTime)}
{track.estimatedArrival.siteName
? `${track.estimatedArrival.siteName}`
: ''}
</Descriptions.Item>
) : null}
</Descriptions>
)}
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
<div style={{ marginBottom: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<Image.PreviewGroup>
<Space wrap>
{track.signPhotoUrls.map((url) => (
<Image
key={url}
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
))}
</Space>
</Image.PreviewGroup>
</div>
) : null}
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{nodes.length > 0 ? (
<Timeline
items={nodes.map((node, index) => {
const isLatest = index === nodes.length - 1;
return {
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
children: (
<div>
{node.statusName ? (
<Typography.Text strong style={{ display: 'block' }}>
{node.statusName}
</Typography.Text>
) : null}
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
{node.trackInfo || '—'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(node.createTime)}
</Typography.Text>
</div>
),
};
})}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
track?.manualQueryUrl
? '暂无实时路由节点,可使用上方物流查询链接'
: '暂无路由信息,请稍后刷新'
}
/>
)}
</Spin>
</Drawer>
);
}
+62 -10
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd'; import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons'; import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd'; import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload'; import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
@@ -15,7 +15,13 @@ type OssUploadProps = {
placeholder?: string; placeholder?: string;
}; };
const DEFAULT_MAX_MB = 10; function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
export default function OssUpload({ export default function OssUpload({
value, value,
@@ -24,22 +30,16 @@ export default function OssUpload({
bizType, bizType,
mediaType = 'IMAGE', mediaType = 'IMAGE',
accept, accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL', placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) { }: OssUploadProps) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
const resolvedAccept = const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*'); accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File; const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true); setUploading(true);
try { try {
const result = await uploadFileToOss(raw, { bizType, mediaType }); const result = await uploadFileToOss(raw, { bizType, mediaType });
@@ -56,6 +56,57 @@ export default function OssUpload({
} }
}; };
const filePreview =
value && mediaType === 'FILE' ? (
isImageUrl(value) ? (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : isPdfUrl(value) ? (
<Space direction="vertical" size={8}>
<div
style={{
width: 120,
height: 120,
borderRadius: 4,
border: '1px solid #f0f0f0',
background: '#fafafa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 36 }} />
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF </span>
</div>
<Space wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
</Button>
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
</Button>
</Space>
<Modal
title="签约合同预览"
open={pdfPreviewOpen}
onCancel={() => setPdfPreviewOpen(false)}
footer={null}
width="90vw"
styles={{ body: { height: '75vh', padding: 0 } }}
destroyOnClose
>
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
</Modal>
</Space>
) : (
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
</Button>
)
) : null;
return ( return (
<Space direction="vertical" style={{ width: '100%' }} size="small"> <Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && ( {value && mediaType === 'IMAGE' && (
@@ -64,6 +115,7 @@ export default function OssUpload({
{value && mediaType === 'VIDEO' && ( {value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} /> <video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)} )}
{filePreview}
<Space wrap> <Space wrap>
<Upload <Upload
accept={resolvedAccept} accept={resolvedAccept}
@@ -0,0 +1,21 @@
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
import MultiImageUpload from './MultiImageUpload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
};
/** 套餐多图:批量上传,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
export default function PackageImagesUpload({ value, onChange }: Props) {
return (
<MultiImageUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
value={value}
onChange={onChange}
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
/>
);
}
@@ -0,0 +1,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>
);
}
+52 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd'; import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { import {
RobotOutlined, RobotOutlined,
@@ -23,6 +23,7 @@ import {
import { hasAnySystemSettingsPermission } from '@dukang/shared-types'; import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api'; import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@@ -146,6 +147,7 @@ const MENU_ITEMS: MenuProps['items'] = [
], ],
}, },
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' }, { key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' }, { key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' }, { key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
]; ];
@@ -215,6 +217,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/third-party': 'logs', '/logs/third-party': 'logs',
'/logs/domain-events': 'logs', '/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions', '/hq-permissions': 'hq_permissions',
'/test-whitelist': 'test_whitelist',
'/system-settings': 'system_settings_any', '/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts', '/hq-accounts': 'hq_accounts',
}; };
@@ -241,6 +244,31 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
.filter(Boolean) as MenuProps['items']; .filter(Boolean) as MenuProps['items'];
} }
function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
if (!items) return items;
return items.map((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) return item;
if ('children' in item && Array.isArray(item.children)) {
return {
...item,
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount),
} as MenuItem;
}
if (String(item.key) === '/store-package-audits') {
return {
...item,
label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
</span>
),
} as MenuItem;
}
return item;
});
}
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging'; const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
export default function AdminLayout() { export default function AdminLayout() {
@@ -248,11 +276,28 @@ export default function AdminLayout() {
const location = useLocation(); const location = useLocation();
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
const [packagePendingCount, setPackagePendingCount] = useState(0);
function refreshPackagePendingCount() {
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
.then((data) => setPackagePendingCount(data.pendingCount ?? 0))
.catch(() => {});
}
useEffect(() => { useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
useEffect(() => {
refreshPackagePendingCount();
}, [location.pathname]);
useEffect(() => {
const onChanged = () => refreshPackagePendingCount();
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
}, []);
useEffect(() => { useEffect(() => {
contentRef.current?.scrollTo({ top: 0, left: 0 }); contentRef.current?.scrollTo({ top: 0, left: 0 });
}, [location.pathname]); }, [location.pathname]);
@@ -271,10 +316,12 @@ export default function AdminLayout() {
: location.pathname; : location.pathname;
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
if (!profile) return MENU_ITEMS; const base =
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS; !profile || profile.adminRole === 'SUPER_ADMIN'
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []); ? MENU_ITEMS
}, [profile]); : filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
return attachPackageAuditBadge(base, packagePendingCount);
}, [profile, packagePendingCount]);
return ( return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}> <Layout style={{ height: '100vh', overflow: 'hidden' }}>
+5
View File
@@ -0,0 +1,5 @@
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
export function notifyPackageAuditChanged() {
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
}
+8
View File
@@ -167,6 +167,13 @@ export type AdminUserRow = {
sourceLabel: string | null; sourceLabel: string | null;
createdAt: string; createdAt: string;
orderCount: number; orderCount: number;
isTest?: boolean;
/** 好客权益·累计获得(含已使用,不含退款作废) */
benefitTotalAmount?: number;
/** 好客权益·已使用(已核销) */
benefitUsedAmount?: number;
/** 好客权益·剩余未使用 */
benefitBalance?: number;
}; };
export type AdminOrderItem = { export type AdminOrderItem = {
@@ -199,6 +206,7 @@ export type AdminOrderRow = {
isProxyOrder?: boolean; isProxyOrder?: boolean;
proxyPartnerName?: string | null; proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null; proxyPartnerPhone?: string | null;
isTest?: boolean;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { delivery?: {
provider: string; provider: string;
+21 -2
View File
@@ -140,10 +140,29 @@ export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—'; return v ? new Date(v).toLocaleString('zh-CN') : '—';
} }
/** 手机号脱敏展示:138****5678;空值返回 — */ /** 联系电话脱敏:手机 138****5678;座机 0379-****888;空值返回 — */
export function maskPhone(phone?: string | null): string { export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim(); const raw = String(phone ?? '').trim().replace(/\s+/g, '');
if (!raw) return '—'; if (!raw) return '—';
if (/^1[3-9]\d{9}$/.test(raw)) {
const digits = raw.replace(/\D/g, '');
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
const extMatch = raw.match(/-(\d{1,6})$/);
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
const ext = hasExt ? extMatch![1] : '';
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
const digits = main.replace(/\D/g, '');
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
const area = digits.slice(0, areaLen);
const local = digits.slice(areaLen);
const keepTail = Math.min(4, Math.max(2, local.length - 4));
const maskedLocal =
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
const joiner = main.includes('-') ? '-' : '';
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
}
const digits = raw.replace(/\D/g, ''); const digits = raw.replace(/\D/g, '');
if (digits.length >= 11) { if (digits.length >= 11) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`; return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
+10 -1
View File
@@ -1,3 +1,5 @@
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
export type StoreCreateForm = { export type StoreCreateForm = {
partnerAccountId: string; partnerAccountId: string;
cityId: string; cityId: string;
@@ -15,6 +17,8 @@ export type StoreCreateForm = {
longitude?: number | null; longitude?: number | null;
intro?: string; intro?: string;
benefitUsageRule?: string; benefitUsageRule?: string;
/** 对外联系电话(店长);可与登录号不同,支持座机 */
contactPhone?: string;
openTime?: string; openTime?: string;
closeTime?: string; closeTime?: string;
openTime2?: string; openTime2?: string;
@@ -22,11 +26,13 @@ export type StoreCreateForm = {
avgPrice?: number | null; avgPrice?: number | null;
coverUrl?: string; coverUrl?: string;
envPhotoUrls?: string[]; envPhotoUrls?: string[];
contractUrl?: string; /** 签约合同,支持多张照片 / PDF */
contractUrls?: string[];
bankAccountName: string; bankAccountName: string;
bankAccountNo: string; bankAccountNo: string;
bankBranch: string; bankBranch: string;
settlementRate?: number; settlementRate?: number;
sortOrder?: number;
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[]; visibilityPhones?: string[];
}; };
@@ -57,6 +63,7 @@ export function validateStoreCreateStep1(
| 'openTime2' | 'openTime2'
| 'closeTime2' | 'closeTime2'
| 'avgPrice' | 'avgPrice'
| 'contactPhone'
>, >,
): string | null { ): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人'; if (!form.partnerAccountId) return '请选择开城合伙人';
@@ -66,6 +73,8 @@ export function validateStoreCreateStep1(
if (!form.name?.trim()) return '请填写门店名称'; if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号'; if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
const contactPhone = form.contactPhone?.trim();
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
if (!form.address?.trim()) return '请填写详细地址'; if (!form.address?.trim()) return '请填写详细地址';
const openTime = String(form.openTime || '').trim(); const openTime = String(form.openTime || '').trim();
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api'; import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
@@ -9,14 +14,24 @@ export type UploadFileResult = {
mock: boolean; mock: boolean;
}; };
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */ /** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export async function uploadFileToOss( export async function uploadFileToOss(
file: File, file: File,
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+13 -82
View File
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types'; import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants'; import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
@@ -242,7 +243,8 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="权益券详情" title="权益券详情"
width={720} width={980}
styles={{ body: { paddingBottom: 24 } }}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={ extra={
@@ -310,14 +312,15 @@ export default function BenefitCouponsPage() {
size="small" size="small"
rowKey="id" rowKey="id"
pagination={false} pagination={false}
tableLayout="fixed"
locale={{ emptyText: '暂无关联核销单' }} locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []} dataSource={detail.redeemRecords ?? []}
columns={[ columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true }, { title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
width: 70, width: 72,
render: (v: string | undefined) => render: (v: string | undefined) =>
v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>, v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>,
}, },
@@ -331,31 +334,31 @@ export default function BenefitCouponsPage() {
{ {
title: '本券分摊', title: '本券分摊',
dataIndex: 'couponAmount', dataIndex: 'couponAmount',
width: 95, width: 96,
render: (v: number | undefined, row: CouponRedeemRecord) => render: (v: number | undefined, row: CouponRedeemRecord) =>
`¥${Number(v ?? row.amount).toFixed(2)}`, `¥${Number(v ?? row.amount).toFixed(2)}`,
}, },
{ {
title: '核销总额', title: '核销总额',
dataIndex: 'amount', dataIndex: 'amount',
width: 90, width: 96,
render: (v: number) => `¥${Number(v).toFixed(2)}`, render: (v: number) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '结算额', title: '结算额',
dataIndex: 'settleAmount', dataIndex: 'settleAmount',
width: 90, width: 96,
render: (v: number) => `¥${Number(v).toFixed(2)}`, render: (v: number) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '时间', title: '时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
width: 150, width: 148,
render: (v: string) => fmtTime(v), render: (v: string) => fmtTime(v),
}, },
{ {
title: '操作', title: '操作',
width: 70, width: 64,
render: (_: unknown, row: CouponRedeemRecord) => ( render: (_: unknown, row: CouponRedeemRecord) => (
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}> <Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
@@ -374,7 +377,7 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="核销单详情" title="核销单详情"
width={520} width={640}
open={redeemDrawerOpen} open={redeemDrawerOpen}
onClose={() => { onClose={() => {
setRedeemDrawerOpen(false); setRedeemDrawerOpen(false);
@@ -385,79 +388,7 @@ export default function BenefitCouponsPage() {
{redeemDetailLoading ? ( {redeemDetailLoading ? (
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
) : redeemDetail ? ( ) : redeemDetail ? (
<Descriptions column={1} bordered size="small"> <RedeemRecordDetailDescriptions detail={redeemDetail} />
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="核销额">
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="结算额">
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户">
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店">
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
? `${(redeemDetail.store as { cityName?: string }).cityName}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店地址">
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="合伙人">
{String(
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
?.partnerAccount?.companyName ?? '—',
)}
</Descriptions.Item>
<Descriptions.Item label="权益券号">
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="关联订单">
{String(
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
'—',
)}
</Descriptions.Item>
{Array.isArray(redeemDetail.allocations) &&
(redeemDetail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="券分摊">
{(
redeemDetail.allocations as Array<{
couponNo?: string;
orderNo?: string | null;
amount?: number;
sortOrder?: number;
}>
)
.map((a, idx) => {
const role = idx === 0 ? '主' : '次';
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
a.orderNo ? `(订单 ${a.orderNo}` : ''
}`;
})
.join('')}
</Descriptions.Item>
) : null}
{redeemDetail.payout ? (
<Descriptions.Item label="门店结算单">
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
{' / '}
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
</Descriptions.Item>
) : null}
{redeemDetail.rating ? (
<Descriptions.Item label="评价">
{(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '}
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'}
</Descriptions.Item>
) : null}
</Descriptions>
) : null} ) : null}
</Drawer> </Drawer>
</div> </div>
+21 -5
View File
@@ -59,6 +59,7 @@ type Row = {
accountCount: number; accountCount: number;
subAccounts?: SubRow[]; subAccounts?: SubRow[];
createdAt: string; createdAt: string;
isTest?: boolean;
}; };
type PartnerDetail = Row & { type PartnerDetail = Row & {
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm(); const [subEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners', '/admin/partners',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName); if (filters.companyName) qs.set('companyName', String(filters.companyName));
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', filters.cityId); if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
}, },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 }, { title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true }, {
title: '主账号姓名',
dataIndex: 'name',
width: 120,
ellipsis: true,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '登录手机', dataIndex: 'phone', width: 120 }, { title: '登录手机', dataIndex: 'phone', width: 120 },
{ {
title: '管辖', title: '管辖',
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="手机"> <Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" /> <Input allowClear placeholder="登录手机" />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
+54 -9
View File
@@ -3,13 +3,26 @@ import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message, Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string; id: string;
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string }; orderId: string;
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
updatedAt: string;
order?: {
id: string;
orderNo: string;
status: string;
receiverName: string;
receiverPhone: string;
deliveryType: string;
};
}; };
export default function DeliveriesPage() { export default function DeliveriesPage() {
@@ -29,6 +42,22 @@ export default function DeliveriesPage() {
); );
const [detail, setDetail] = useState<Row | null>(null); const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
function openTrack(row: Row) {
const orderId = row.orderId || row.order?.id;
if (!orderId) {
message.warning('缺少关联订单,无法查询路由');
return;
}
setTrackOrderId(orderId);
setTrackOrderNo(row.order?.orderNo ?? null);
setTrackOrderStatus(row.order?.status ?? null);
setTrackOpen(true);
}
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 }, { title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
@@ -39,14 +68,17 @@ export default function DeliveriesPage() {
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 }, { title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime }, { title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 80, title: '操作', width: 140,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={async () => { <Space size={0}>
const d = await request<Row>(`/admin/deliveries/${row.id}`); <Button type="link" size="small" onClick={() => openTrack(row)}></Button>
setDetail(d); <Button type="link" size="small" onClick={async () => {
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo }); const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDrawerOpen(true); setDetail(d);
}}></Button> editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}></Button>
</Space>
), ),
}, },
]; ];
@@ -87,9 +119,22 @@ export default function DeliveriesPage() {
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item> <Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item> <Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form> </Form>
<Button
style={{ marginTop: 8 }}
onClick={() => openTrack(detail)}
>
</Button>
</> </>
)} )}
</Drawer> </Drawer>
<OrderTrackDrawer
open={trackOpen}
orderId={trackOrderId}
orderNo={trackOrderNo}
orderStatus={trackOrderStatus}
onClose={() => setTrackOpen(false)}
/>
</div> </div>
); );
} }
+101 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Button, Button,
Form, Form,
@@ -16,9 +16,12 @@ import type { ColumnsType } from 'antd/es/table';
import { import {
DEV_PLAN_TASK_STATUS_LABELS, DEV_PLAN_TASK_STATUS_LABELS,
DEV_PLAN_TASK_TYPE_LABELS, DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto, type DevPlanTaskDto,
type DevPlanTaskStatusDto, type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto, type DevPlanTaskTypeDto,
type DevPlanVersionDto,
type SupportTicketDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
@@ -46,11 +49,30 @@ export default function DevPlanTasksPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [dispatchOpen, setDispatchOpen] = useState(false); const [dispatchOpen, setDispatchOpen] = useState(false);
const [batchEditOpen, setBatchEditOpen] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null); const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false); const [dispatching, setDispatching] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [dispatchForm] = Form.useForm<{ supplement?: string }>(); const [dispatchForm] = Form.useForm<{ supplement?: string }>();
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
useEffect(() => {
if (!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>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
'/admin/dev-plan/tasks', '/admin/dev-plan/tasks',
@@ -66,7 +88,7 @@ export default function DevPlanTasksPage() {
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO' }); form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
setModalOpen(true); setModalOpen(true);
} }
@@ -89,7 +111,11 @@ export default function DevPlanTasksPage() {
} else { } else {
await request('/admin/dev-plan/tasks', { await request('/admin/dev-plan/tasks', {
method: 'POST', method: 'POST',
body: JSON.stringify({ content: values.content, type: values.type }), body: JSON.stringify({
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
}),
}); });
message.success('已创建'); message.success('已创建');
} }
@@ -119,6 +145,38 @@ export default function DevPlanTasksPage() {
setDispatchOpen(true); setDispatchOpen(true);
} }
function openBatchEdit() {
batchForm.setFieldsValue({ status: undefined, versionId: undefined });
setBatchEditOpen(true);
}
async function submitBatchEdit() {
const values = await batchForm.validateFields();
if (!values.status && !values.versionId) {
message.warning('请至少选择状态或关联版本');
return;
}
setBatchSaving(true);
try {
await request('/admin/dev-plan/tasks/batch-update', {
method: 'POST',
body: JSON.stringify({
taskIds: selectedRowKeys,
status: values.status || undefined,
versionId: values.versionId || undefined,
}),
});
message.success('批量更新成功');
setBatchEditOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量更新失败');
} finally {
setBatchSaving(false);
}
}
async function submitDispatch() { async function submitDispatch() {
const values = await dispatchForm.validateFields(); const values = await dispatchForm.validateFields();
setDispatching(true); setDispatching(true);
@@ -186,6 +244,9 @@ export default function DevPlanTasksPage() {
· ·
</Typography.Title> </Typography.Title>
<Space> <Space>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}> <Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
</Button> </Button>
@@ -258,7 +319,20 @@ export default function DevPlanTasksPage() {
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
) : null} ) : (
<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> </Form>
</Modal> </Modal>
@@ -280,6 +354,29 @@ export default function DevPlanTasksPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title="批量编辑任务"
open={batchEditOpen}
onCancel={() => setBatchEditOpen(false)}
onOk={() => void submitBatchEdit()}
confirmLoading={batchSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary"> {selectedRowKeys.length} </Typography.Paragraph>
<Form form={batchForm} layout="vertical">
<Form.Item name="status" label="状态(不修改请留空)">
<Select allowClear placeholder="不修改" options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="versionId" label="关联版本(追加关联,不修改请留空)">
<Select
allowClear
placeholder="不修改"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
+92 -19
View File
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import type { import type {
KnowledgeBaseDto, KnowledgeBaseDto,
KnowledgeDocumentDetailDto,
KnowledgeDocumentDto, KnowledgeDocumentDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
@@ -49,6 +50,10 @@ export default function KnowledgeBasesPage() {
const [docsLoading, setDocsLoading] = useState(false); const [docsLoading, setDocsLoading] = useState(false);
const [docModalOpen, setDocModalOpen] = useState(false); const [docModalOpen, setDocModalOpen] = useState(false);
const [docSaving, setDocSaving] = useState(false); const [docSaving, setDocSaving] = useState(false);
const [editingDoc, setEditingDoc] = useState<KnowledgeDocumentDto | null>(null);
const [editDocModalOpen, setEditDocModalOpen] = useState(false);
const [editDocSaving, setEditDocSaving] = useState(false);
const [editDocForm] = Form.useForm<DocForm>();
const { data, loading, page, pageSize, setPage, setPageSize, reload } = const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<KnowledgeBaseDto>( useAdminList<KnowledgeBaseDto>(
@@ -195,28 +200,52 @@ export default function KnowledgeBasesPage() {
}, },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
render: (_, row) => render: (_, row) =>
drawerKb?.canEditFull ? ( drawerKb?.canEditFull ? (
<Popconfirm <Space>
title="删除文档?" <Button
onConfirm={async () => { type="link"
try { size="small"
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, { onClick={async () => {
method: 'DELETE', if (!drawerKb) return;
}); try {
message.success('已删除'); const detail = await request<KnowledgeDocumentDetailDto>(
void loadDocs(drawerKb); `/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`,
reload(); );
} catch (e) { setEditingDoc(row);
message.error(e instanceof Error ? e.message : String(e)); editDocForm.setFieldsValue({
} title: detail.title,
}} contentText: detail.contentText || '',
> });
<Button type="link" size="small" danger> setEditDocModalOpen(true);
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
</Button> </Button>
</Popconfirm> <Popconfirm
title="删除文档?"
onConfirm={async () => {
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
method: 'DELETE',
});
message.success('已删除');
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
) : null, ) : null,
}, },
]; ];
@@ -398,6 +427,50 @@ export default function KnowledgeBasesPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title="编辑知识文档"
open={editDocModalOpen}
onCancel={() => {
setEditDocModalOpen(false);
setEditingDoc(null);
}}
confirmLoading={editDocSaving}
onOk={async () => {
if (!drawerKb || !editingDoc) return;
const values = await editDocForm.validateFields();
setEditDocSaving(true);
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${editingDoc.id}`, {
method: 'PUT',
body: JSON.stringify({
title: values.title,
contentText: values.contentText || null,
}),
});
message.success('已保存');
setEditDocModalOpen(false);
setEditingDoc(null);
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setEditDocSaving(false);
}
}}
destroyOnClose
width={640}
>
<Form form={editDocForm} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contentText" label="正文(可粘贴)">
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
+40 -2
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Checkbox,
Collapse, Collapse,
Descriptions, Descriptions,
Drawer, Drawer,
@@ -30,6 +31,7 @@ import {
} from '../lib/constants'; } from '../lib/constants';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types'; import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
import ProxyOrderModal from '../components/ProxyOrderModal'; import ProxyOrderModal from '../components/ProxyOrderModal';
import OrderTrackDrawer from '../components/OrderTrackDrawer';
type ShipDefaults = { type ShipDefaults = {
provider: string; provider: string;
@@ -186,6 +188,7 @@ export default function OrdersPage() {
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS'); const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]); const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false); const [proxyOpen, setProxyOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
@@ -223,6 +226,7 @@ export default function OrdersPage() {
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res); setData(res);
} finally { } finally {
@@ -430,7 +434,17 @@ export default function OrdersPage() {
} }
const columns: ColumnsType<AdminOrderRow> = [ const columns: ColumnsType<AdminOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180 }, {
title: '订单号',
dataIndex: 'orderNo',
width: 200,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ {
title: '城市', title: '城市',
width: 90, width: 90,
@@ -598,6 +612,9 @@ export default function OrdersPage() {
options={[{ value: true, label: '仅待确认大单' }]} options={[{ value: true, label: '仅待确认大单' }]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
@@ -859,7 +876,20 @@ export default function OrdersPage() {
</Descriptions> </Descriptions>
{detail.delivery && ( {detail.delivery && (
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}> <Descriptions
column={1}
bordered
size="small"
title={
<Space>
<span></span>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setTrackOpen(true)}>
</Button>
</Space>
}
style={{ marginTop: 16 }}
>
<Descriptions.Item label="快递公司"> <Descriptions.Item label="快递公司">
{detail.delivery.logisticsCompany || {detail.delivery.logisticsCompany ||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
@@ -1232,6 +1262,14 @@ export default function OrdersPage() {
void openDetail(order.id); void openDetail(order.id);
}} }}
/> />
<OrderTrackDrawer
open={trackOpen}
orderId={detail?.id ?? null}
orderNo={detail?.orderNo}
orderStatus={detail?.status}
onClose={() => setTrackOpen(false)}
/>
</div> </div>
); );
} }
@@ -34,7 +34,7 @@ function mapToForm(d: Record<string, unknown>) {
const row = d as Row; const row = d as Row;
return { return {
...row, ...row,
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''], detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
features: row.features?.length features: row.features?.length
? row.features ? row.features
: [{ icon: 'star', title: '', desc: '' }], : [{ icon: 'star', title: '', desc: '' }],
@@ -68,12 +68,14 @@ function buildPayload(v: TemplateFormValues) {
function TemplateContentFields() { function TemplateContentFields() {
return ( return (
<> <>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
<DetailImageUrlList <Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
label="详情图" <DetailImageUrlList
bizType="DETAIL_TEMPLATE" label="详情图"
maxCount={TEMPLATE_MAX_DETAIL_IMAGES} bizType="DETAIL_TEMPLATE"
/> maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
/>
</Form.Item>
<Divider /> <Divider />
<Form.Item name="storyTitle" label="故事标题"> <Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" /> <Input placeholder="如:千年杜康 · 唯有此处" />
@@ -228,7 +230,7 @@ export default function ProductDetailTemplatesPage() {
}} width={640}> }} width={640}>
<Form form={createForm} layout="vertical" initialValues={{ <Form form={createForm} layout="vertical" initialValues={{
status: 'ACTIVE', sortOrder: 0, status: 'ACTIVE', sortOrder: 0,
detailImageUrls: [''], detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }], features: [{ icon: 'water_drop', title: '', desc: '' }],
}}> }}>
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic"> <Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
+23 -103
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message, Switch, Table, Tabs, Tag, Typography, message,
@@ -9,6 +9,7 @@ import { request } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import DetailImageUrlList from '../components/DetailImageUrlList'; import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker'; import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import type { FormInstance } from 'antd/es/form'; import type { FormInstance } from 'antd/es/form';
@@ -67,21 +68,14 @@ type ProductFormValues = {
features?: Array<{ icon?: string; title?: string; desc?: string }>; features?: Array<{ icon?: string; title?: string; desc?: string }>;
}; };
type UserPickRow = {
id: string;
phone?: string | null;
nickname?: string | null;
userNo?: string;
};
function mapDetailToForm(d: Record<string, unknown>) { function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto; const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row; const row = d as Row;
return { return {
...d, ...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl, coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[], carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[], detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
allowOnlinePurchase: row.allowOnlinePurchase !== false, allowOnlinePurchase: row.allowOnlinePurchase !== false,
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false, allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
allowOnSitePickup: !!row.allowOnSitePickup, allowOnSitePickup: !!row.allowOnSitePickup,
@@ -112,10 +106,6 @@ function buildProductPayload(v: ProductFormValues) {
features: features.length ? features : undefined, features: features.length ? features : undefined,
}; };
const visibilityPhones = (v.visibilityPhones ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean);
return { return {
barcode69: v.barcode69, barcode69: v.barcode69,
name: v.name, name: v.name,
@@ -131,7 +121,6 @@ function buildProductPayload(v: ProductFormValues) {
allowCrossCityDelivery: allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false, v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl, coverUrl: v.coverUrl,
carouselUrls, carouselUrls,
detailImageUrls, detailImageUrls,
@@ -141,25 +130,13 @@ function buildProductPayload(v: ProductFormValues) {
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) { function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return ( return (
<Form.List name={name}> <Form.Item name={name} style={{ marginBottom: 0 }}>
{(fields, { add, remove }) => ( <MultiImageUpload
<> bizType={bizType}
{fields.map((field) => ( mediaType="IMAGE"
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}> tip={`${label}支持一次选择多张批量上传`}
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}> />
<OssUpload bizType={bizType} mediaType="IMAGE" /> </Form.Item>
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
</>
)}
</Form.List>
); );
} }
@@ -168,11 +145,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
<> <>
<ProductDetailTemplatePicker form={form} aromaType={aromaType} /> <ProductDetailTemplatePicker form={form} aromaType={aromaType} />
<Divider /> <Divider />
<Typography.Text type="secondary">CAROUSEL</Typography.Text> <Typography.Text type="secondary">CAROUSEL 10MB</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" /> <ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider /> <Divider />
<Typography.Text type="secondary">DETAIL</Typography.Text> <Typography.Text type="secondary">DETAIL 10MB</Typography.Text>
<DetailImageUrlList label="详情图" bizType="DETAIL" /> <Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
</Form.Item>
<Divider /> <Divider />
<Form.Item name="storyTitle" label="故事标题"> <Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" /> <Input placeholder="如:千年杜康 · 唯有此处" />
@@ -213,33 +192,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
} }
function VisibilityWhitelistFields({ form }: { form: FormInstance }) { function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form); const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return ( return (
@@ -248,46 +200,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled" name="visibilityWhitelistEnabled"
label="可见白名单" label="可见白名单"
valuePropName="checked" valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试" extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
> >
<Switch checkedChildren="开" unCheckedChildren="关" /> <Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item> </Form.Item>
{enabled ? ( {enabled ? (
<> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
<Form.Item
name="visibilityPhones" </Typography.Text>
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : null} ) : null}
</> </>
); );
@@ -419,8 +339,8 @@ export default function ProductsPage() {
title: '白名单', title: '白名单',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
width: 90, width: 90,
render: (v: boolean, row) => render: (v: boolean) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>, v ? <Tag color="orange"></Tag> : <Tag></Tag>,
}, },
{ {
title: '履约', title: '履约',
@@ -539,7 +459,7 @@ export default function ProductsPage() {
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false, allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [], visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [''], detailImageUrls: [''], carouselUrls: [], detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }], features: [{ icon: 'water_drop', title: '', desc: '' }],
}}> }}>
<Tabs items={[ <Tabs items={[
+73 -33
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -13,30 +14,62 @@ type Row = {
settleAmount: number; settleAmount: number;
channel?: RedeemChannel; channel?: RedeemChannel;
createdAt: string; createdAt: string;
user?: { userNo: string; phone: string | null }; isTest?: boolean;
user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string }; store?: { name: string; cityName: string };
coupon?: { couponNo: 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() { export default function RedeemRecordsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records', '/admin/redeem-records',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo); if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', String(filters.storeId));
if (filters.channel) qs.set('channel', filters.channel); if (filters.channel) qs.set('channel', String(filters.channel));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
async function openDetail(id: string) {
setDetailLoading(true);
setDrawerOpen(true);
try {
setDetail(await request(`/admin/redeem-records/${id}`));
} finally {
setDetailLoading(false);
}
}
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 }, {
title: '核销号',
dataIndex: 'redeemNo',
width: 200,
render: (v, row) => (
<span>
{v}
{row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}>
</Tag>
) : null}
</span>
),
},
{ {
title: '方式', title: '方式',
dataIndex: 'channel', dataIndex: 'channel',
@@ -50,7 +83,19 @@ export default function RedeemRecordsPage() {
); );
}, },
}, },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 }, { title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
{
title: '用户昵称',
dataIndex: ['user', 'nickname'],
width: 100,
render: (v: string | null | undefined) => v || '—',
},
{
title: '用户手机',
dataIndex: ['user', 'phone'],
width: 120,
render: (v: string | null | undefined) => maskPhone(v),
},
{ title: '门店', dataIndex: ['store', 'name'] }, { title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, { title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` }, { title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
@@ -60,14 +105,7 @@ export default function RedeemRecordsPage() {
title: '操作', title: '操作',
width: 80, width: 80,
render: (_, row) => ( render: (_, row) => (
<Button <Button type="link" size="small" onClick={() => void openDetail(row.id)}>
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/redeem-records/${row.id}`));
setDrawerOpen(true);
}}
>
</Button> </Button>
), ),
@@ -102,6 +140,9 @@ export default function RedeemRecordsPage() {
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -113,7 +154,7 @@ export default function RedeemRecordsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 1300 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -125,22 +166,21 @@ export default function RedeemRecordsPage() {
}, },
}} }}
/> />
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer
{detail && ( title="核销详情"
<Descriptions column={1} bordered size="small"> width={640}
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item> open={drawerOpen}
<Descriptions.Item label="方式"> onClose={() => {
{ setDrawerOpen(false);
REDEEM_CHANNEL_LABELS[ setDetail(null);
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel }}
] destroyOnClose
} >
</Descriptions.Item> {detailLoading ? (
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item> <Typography.Text type="secondary"></Typography.Text>
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item> ) : detail ? (
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item> <RedeemRecordDetailDescriptions detail={detail} />
</Descriptions> ) : null}
)}
</Drawer> </Drawer>
</div> </div>
); );
+20 -5
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
@@ -15,6 +15,7 @@ type Row = {
name: string; name: string;
status: string; status: string;
createdAt: string; createdAt: string;
isTest?: boolean;
storeCount?: number; storeCount?: number;
staffCount?: number; staffCount?: number;
bankAccountName?: string | null; bankAccountName?: string | null;
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() { export default function StoreAccountsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts', '/admin/store-accounts',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', String(filters.status));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 }, {
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '绑定门店', title: '绑定门店',
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table <Table
@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { import {
Badge,
Button, Button,
Drawer, Drawer,
Image,
Input, Input,
Modal, Modal,
Space, Space,
@@ -13,19 +16,97 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { import type {
StorePackageAuditDetailDto, StorePackageAuditDetailDto,
StorePackageAuditSummaryDto,
StorePackageChangeRequestDto, StorePackageChangeRequestDto,
StorePackageChangeStatus,
StorePackageItemDto, StorePackageItemDto,
StorePackageViewDto, StorePackageViewDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types'; import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核',
APPROVED: '已通过',
REJECTED: '已驳回',
};
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) { function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
const name = String(pkg.name ?? '').trim(); const name = String(pkg.name ?? '').trim();
return name ? `name:${name}` : `idx:${index}`; return name ? `name:${name}` : `idx:${index}`;
} }
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
return normalizeStorePackageImageUrls(pkg).join('|');
}
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
/** 文本逐字差异:LCS 比对,产出 equal / delete / insert 段落,用于高亮具体变了哪些字 */
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
let i = 0;
let j = 0;
while (i < m && j < n) {
if (a[i] === b[j]) {
raw.push({ type: 'equal', text: a[i] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
raw.push({ type: 'delete', text: a[i] });
i++;
} else {
raw.push({ type: 'insert', text: b[j] });
j++;
}
}
while (i < m) raw.push({ type: 'delete', text: a[i++] });
while (j < n) raw.push({ type: 'insert', text: b[j++] });
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
for (const s of raw) {
const last = merged[merged.length - 1];
if (last && last.type === s.type) last.text += s.text;
else merged.push({ ...s });
}
return merged;
}
/** 逐字段比较套餐内容,返回发生变化的字段明细(用于“变动的地方详细列出”) */
function fieldChanges(
live: StorePackageItemDto | StorePackageViewDto,
proposed: StorePackageItemDto | StorePackageViewDto,
): FieldChange[] {
const changes: FieldChange[] = [];
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
const pushText = (label: string, oldV: string, newV: string) => {
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
};
const pushValue = (label: string, oldV: string, newV: string) => {
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
};
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
pushText('套餐名称', text(live.name), text(proposed.name));
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
const liveImgs = normalizeStorePackageImageUrls(live);
const proposedImgs = normalizeStorePackageImageUrls(proposed);
if (imageSignature(live) !== imageSignature(proposed)) {
changes.push({ label: '图片', old: `${liveImgs.length}`, now: `${proposedImgs.length}`, kind: 'value' });
}
return changes;
}
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) { function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p])); 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 proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
@@ -35,6 +116,7 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
change: 'added' | 'removed' | 'changed' | 'unchanged'; change: 'added' | 'removed' | 'changed' | 'unchanged';
live?: StorePackageViewDto; live?: StorePackageViewDto;
proposed?: StorePackageItemDto; proposed?: StorePackageItemDto;
changes?: FieldChange[];
}> = []; }> = [];
for (const key of keys) { for (const key of keys) {
@@ -49,8 +131,15 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
l.price !== p.price || l.price !== p.price ||
l.dishes !== p.dishes || l.dishes !== p.dishes ||
(l.usableTime ?? '') !== (p.usableTime ?? '') || (l.usableTime ?? '') !== (p.usableTime ?? '') ||
(l.otherNotes ?? '') !== (p.otherNotes ?? ''); (l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p }); imageSignature(l) !== imageSignature(p);
rows.push({
key,
change: changed ? 'changed' : 'unchanged',
live: l,
proposed: p,
changes: changed ? fieldChanges(l, p) : undefined,
});
} }
} }
return rows; return rows;
@@ -63,12 +152,158 @@ const CHANGE_LABELS = {
unchanged: { text: '未变', color: 'default' }, unchanged: { text: '未变', color: 'default' },
} as const; } as const;
/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
const segs = diffText(oldText, newText);
return (
<div style={{ marginTop: 2 }}>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'insert')
.map((s, idx) =>
s.type === 'delete' ? (
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'delete')
.map((s, idx) =>
s.type === 'insert' ? (
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
</div>
);
}
function PackageDetailCard({
title,
pkg,
change,
changes,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
change?: keyof typeof CHANGE_LABELS;
changes?: FieldChange[];
}) {
const images = normalizeStorePackageImageUrls(pkg);
const meta = change ? CHANGE_LABELS[change] : null;
return (
<div
style={{
marginBottom: 12,
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
<Space style={{ marginBottom: 8 }} wrap>
{title ? (
<Typography.Text type="secondary">{title}</Typography.Text>
) : null}
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
</Space>
<div style={{ marginBottom: 8 }}>
<strong>{pkg.name}</strong>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
</div>
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 4 }}
>
{pkg.usableTime}
</Typography.Paragraph>
) : null}
{pkg.otherNotes ? (
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 8 }}
>
{pkg.otherNotes}
</Typography.Paragraph>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image
key={url}
src={url}
width={72}
height={72}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
{changes && changes.length ? (
<div
style={{
marginTop: 8,
padding: 8,
background: '#fff7e6',
border: '1px solid #ffe7ba',
borderRadius: 6,
}}
>
<Typography.Text strong style={{ fontSize: 12 }}>
</Typography.Text>
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{changes.map((c) => (
<li key={c.label} style={{ marginBottom: 6 }}>
<Typography.Text type="secondary">{c.label}</Typography.Text>
{c.kind === 'text' ? (
<TextDiff oldText={c.old} newText={c.now} />
) : (
<>
<Typography.Text delete type="secondary">
{c.old}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>{c.now}</Typography.Text>
</>
)}
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
export default function StorePackageAuditsPage() { export default function StorePackageAuditsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]); const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('PENDING'); const [status, setStatus] = useState<string>('PENDING');
const [pendingCount, setPendingCount] = useState(0);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
@@ -84,12 +319,14 @@ export default function StorePackageAuditsPage() {
pageSize: '20', pageSize: '20',
}); });
if (nextStatus) qs.set('status', nextStatus); if (nextStatus) qs.set('status', nextStatus);
const data = await request<Paginated<StorePackageChangeRequestDto>>( const [data, summary] = await Promise.all([
`/admin/store-package-audits?${qs}`, request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`),
); request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'),
]);
setItems(data.items); setItems(data.items);
setTotal(data.total); setTotal(data.total);
setPage(data.page); setPage(data.page);
setPendingCount(summary.pendingCount ?? 0);
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '加载失败'); message.error(e instanceof Error ? e.message : '加载失败');
} finally { } finally {
@@ -101,6 +338,14 @@ export default function StorePackageAuditsPage() {
void reload(1, status); void reload(1, status);
}, [status]); }, [status]);
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
const [searchParams] = useSearchParams();
useEffect(() => {
const rid = searchParams.get('requestId');
if (rid) void openDetail(rid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function openDetail(id: string) { async function openDetail(id: string) {
setDetailOpen(true); setDetailOpen(true);
setDetailLoading(true); setDetailLoading(true);
@@ -126,6 +371,7 @@ export default function StorePackageAuditsPage() {
}); });
message.success(action === 'APPROVE' ? '已通过' : '已驳回'); message.success(action === 'APPROVE' ? '已通过' : '已驳回');
setDetailOpen(false); setDetailOpen(false);
notifyPackageAuditChanged();
void reload(page, status); void reload(page, status);
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '操作失败'); message.error(e instanceof Error ? e.message : '操作失败');
@@ -133,54 +379,12 @@ export default function StorePackageAuditsPage() {
} }
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : []; const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [ const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
{ const addedCount = diffRows.filter((r) => r.change === 'added').length;
title: '变更', const removedCount = diffRows.filter((r) => r.change === 'removed').length;
dataIndex: 'change', const changedCount = diffRows.filter((r) => r.change === 'changed').length;
width: 72, const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
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> = [ const columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, { title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
@@ -188,7 +392,7 @@ export default function StorePackageAuditsPage() {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
render: (v: StorePackageChangeRequestDto['status']) => ( render: (v: StorePackageChangeRequestDto['status']) => (
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag> <Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
), ),
}, },
{ {
@@ -238,7 +442,15 @@ export default function StorePackageAuditsPage() {
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}> <Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'} {s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{HQ_PACKAGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
HQ_PACKAGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button> </Button>
))} ))}
</Space> </Space>
@@ -257,7 +469,7 @@ export default function StorePackageAuditsPage() {
<Drawer <Drawer
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'} title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
width={720} width={880}
open={detailOpen} open={detailOpen}
onClose={() => setDetailOpen(false)} onClose={() => setDetailOpen(false)}
extra={ extra={
@@ -282,8 +494,8 @@ export default function StorePackageAuditsPage() {
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
) : detail ? ( ) : detail ? (
<> <>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag> <Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)} {detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
</Typography.Text> </Typography.Text>
@@ -291,16 +503,54 @@ export default function StorePackageAuditsPage() {
{detail.rejectReason ? ( {detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph> <Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null} ) : null}
<Typography.Paragraph type="secondary"> <Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0} <Typography.Text type="secondary">
</Typography.Paragraph> 线 {detail.livePackages?.length ?? 0} · {detail.packages?.length ?? 0}
<Table </Typography.Text>
size="small" <Space wrap>
rowKey="key" <Tag color="green"> {addedCount}</Tag>
columns={diffColumns} <Tag color="red"> {removedCount}</Tag>
dataSource={diffRows} <Tag color="orange"> {changedCount}</Tag>
pagination={false} {unchangedCount ? <Tag> {unchangedCount}</Tag> : null}
/> </Space>
</Space>
<div className="admin-package-audit-cols">
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
线
</Typography.Title>
{(detail.livePackages ?? []).length ? (
(detail.livePackages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`live-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary">线</Typography.Text>
)}
</div>
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{(detail.packages ?? []).length ? (
(detail.packages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`pending-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</div>
</> </>
) : null} ) : null}
</Drawer> </Drawer>
+278 -295
View File
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Checkbox,
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
@@ -22,11 +23,11 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd/es/form'; import type { FormInstance } from 'antd/es/form';
import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons'; import { EnvironmentOutlined } from '@ant-design/icons';
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { import {
ADMIN_OPTIONS_PAGE_SIZE, ADMIN_OPTIONS_PAGE_SIZE,
RESOURCE_BIZ_TYPE_LABELS,
STORE_AUDIT_STATUS_LABELS, STORE_AUDIT_STATUS_LABELS,
STORE_STATUS_LABELS, STORE_STATUS_LABELS,
fmtTime, fmtTime,
@@ -40,8 +41,11 @@ import { useAdminList } from '../lib/useAdminList';
import { resolveRegionBinding } from '../lib/china-region'; import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader'; import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import TencentLocPickerModal from '../components/TencentLocPickerModal'; import TencentLocPickerModal from '../components/TencentLocPickerModal';
import AdminStorePackagesSection from '../components/AdminStorePackagesSection'; import AdminStorePackagesSection, {
type AdminStorePackagesHandle,
} from '../components/AdminStorePackagesSection';
const CREATE_STEPS = [ const CREATE_STEPS = [
{ title: '基本信息' }, { title: '基本信息' },
@@ -81,18 +85,6 @@ type StoreMediaItem = {
url?: string | null; url?: string | null;
}; };
function isImageMedia(url: string, mediaType?: string) {
if (mediaType === 'IMAGE') return true;
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
}
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
function collectMediaUrls(detail: Record<string, unknown>) { function collectMediaUrls(detail: Record<string, unknown>) {
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : []; const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
const byType = (bizType: string) => const byType = (bizType: string) =>
@@ -118,195 +110,50 @@ function collectMediaUrls(detail: Record<string, unknown>) {
}; };
} }
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) { function StoreAuditMediaEditor() {
const { covers, envs, contracts } = collectMediaUrls(detail);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
return (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
/>
);
}
return ( return (
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}> <Alert
type="info"
</Typography.Title> showIcon
style={{ marginBottom: 16 }}
{(covers.length > 0 || envs.length > 0) && ( message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照、签约合同均最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
<div style={{ marginBottom: 16 }}> />
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> <Form.Item name="coverUrl" label="门头照">
/ <OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Typography.Text> </Form.Item>
<Image.PreviewGroup> <Form.Item
<Space wrap size={12}> name="envPhotoUrls"
{gallery.map((item) => ( label="环境照片"
<div key={item.id} style={{ textAlign: 'center' }}> extra="建议至少 3 张;支持批量上传,最多 20 张。可逐张删除后保存。"
<Image
src={item.url}
width={112}
height={84}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
</div>
</div>
))}
</Space>
</Image.PreviewGroup>
</div>
)}
{contracts.length > 0 && (
<div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{contracts.map((item, index) => {
const imageLike = isImageMedia(item.url, item.mediaType);
const pdf = isPdfUrl(item.url);
return (
<div
key={item.id || `${item.url}-${index}`}
style={{
display: 'flex',
gap: 12,
alignItems: 'center',
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
{imageLike ? (
<Image.PreviewGroup>
<Image
src={item.url}
width={96}
height={72}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
</Image.PreviewGroup>
) : (
<div
style={{
width: 96,
height: 72,
borderRadius: 6,
background: '#fff',
border: '1px dashed #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 28 }} />
</div>
)}
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text strong>
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
{contracts.length > 1 ? ` ${index + 1}` : ''}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
{item.url}
</Typography.Text>
<Space wrap>
{imageLike ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
{pdf ? (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
PDF
</Button>
) : null}
<Button
type="link"
size="small"
icon={<LinkOutlined />}
style={{ padding: 0 }}
href={item.url}
target="_blank"
rel="noreferrer"
>
</Button>
</Space>
</Space>
</div>
);
})}
</Space>
</div>
)}
<Modal
title="合同预览"
open={!!pdfUrl}
onCancel={() => setPdfUrl(null)}
width={900}
footer={[
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
</Button>,
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
</Button>,
]}
destroyOnClose
> >
{pdfUrl ? ( <MultiImageUpload
<iframe bizType="STORE_ENV"
title="合同 PDF 预览" mediaType="IMAGE"
src={pdfUrl} maxCount={20}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }} tip="环境照支持一次选择多张批量上传"
/> />
) : null} </Form.Item>
</Modal> <Form.Item
name="contractUrls"
label="签约合同"
style={{ marginTop: 16 }}
extra="支持多张合同照片(如首页、盖章页),也可上传 PDF,最多 20 个。"
>
<MultiImageUpload
bizType="STORE_CONTRACT"
mediaType="FILE"
accept="image/*,.pdf"
maxCount={20}
buttonText="批量上传合同"
tip="合同支持一次选择多张照片批量上传,最多 20 个"
/>
</Form.Item>
</div> </div>
); );
} }
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) { function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form); const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return ( return (
@@ -315,46 +162,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled" name="visibilityWhitelistEnabled"
label="可见白名单" label="可见白名单"
valuePropName="checked" valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见,用于在线测试" extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
> >
<Switch checkedChildren="开" unCheckedChildren="关" /> <Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item> </Form.Item>
{enabled ? ( {enabled ? (
<> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
<Form.Item
name="visibilityPhones" </Typography.Text>
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : null} ) : null}
</> </>
); );
@@ -375,8 +190,11 @@ type StoreRow = {
createdAt: string; createdAt: string;
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[]; visibilityPhones?: string[];
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
pendingPackageAuditId?: string | null;
isTest?: boolean;
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { companyName: string }; partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
account?: { account?: {
phone: string; phone: string;
name: string; name: string;
@@ -391,16 +209,18 @@ type StoreRow = {
closeTime2?: string | null; closeTime2?: string | null;
avgPrice?: number | null; avgPrice?: number | null;
settlementRate?: number; settlementRate?: number;
sortOrder?: number;
category?: { id: string; name: string; parentId?: string | null } | null; category?: { id: string; name: string; parentId?: string | null } | null;
}; };
type PartnerOption = { id: string; companyName: string; name?: string }; type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
function partnerOptionLabel(p: PartnerOption): string { function partnerOptionLabel(p: PartnerOption): string {
const company = (p.companyName || '').trim(); const company = (p.companyName || '').trim();
const person = (p.name || '').trim(); const person = (p.name || '').trim();
const phone = (p.phone || '').trim();
if (company && person) return `${company}-${person}`; if (company && person) return `${company}-${person}`;
return company || person || p.id; return company || person || phone || p.id;
} }
type CityOption = { type CityOption = {
id: string; id: string;
@@ -423,8 +243,8 @@ export default function StoresPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>(); const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string>>(() => { const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string> = {}; const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId; if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId; if (initialPartnerId) init.partnerId = initialPartnerId;
return init; return init;
@@ -433,12 +253,13 @@ export default function StoresPage() {
'/admin/stores', '/admin/stores',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name); if (filters.name) qs.set('name', String(filters.name));
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', String(filters.status));
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus); if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', filters.cityId); if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.partnerId) qs.set('partnerId', filters.partnerId); if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -447,6 +268,7 @@ export default function StoresPage() {
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]); const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const packagesRef = useRef<AdminStorePackagesHandle>(null);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [auditing, setAuditing] = useState(false); const [auditing, setAuditing] = useState(false);
@@ -561,12 +383,18 @@ export default function StoresPage() {
account?.phone || account?.phone ||
(typeof d.phone === 'string' ? d.phone : undefined); (typeof d.phone === 'string' ? d.phone : undefined);
const storePhone = typeof d.phone === 'string' ? d.phone : undefined; const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
const contactPhone =
(typeof d.contactPhone === 'string' && d.contactPhone.trim()) ||
storePhone ||
loginPhone ||
'';
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone); const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null); setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
editForm.setFieldsValue({ editForm.setFieldsValue({
name: d.name, name: d.name,
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号 // 登录手机号(老板);与 StoreAccount 同步
phone: storePhone || loginPhone, phone: loginPhone || storePhone,
contactPhone,
intro: d.intro, intro: d.intro,
benefitUsageRule: benefitUsageRule:
d.benefitUsageRule != null && d.benefitUsageRule != null &&
@@ -575,6 +403,15 @@ export default function StoresPage() {
? String(d.benefitUsageRule) ? String(d.benefitUsageRule)
: '', : '',
coverUrl: d.coverUrl, coverUrl: d.coverUrl,
envPhotoUrls: (() => {
const { envs } = collectMediaUrls(d);
const urls = envs.map((item) => item.url).filter(Boolean);
return urls;
})(),
contractUrls: (() => {
const { contracts } = collectMediaUrls(d);
return contracts.map((item) => item.url).filter(Boolean);
})(),
province: d.province, province: d.province,
city: d.cityName, city: d.cityName,
district: d.district, district: d.district,
@@ -596,6 +433,8 @@ export default function StoresPage() {
visibilityPhones: Array.isArray(d.visibilityPhones) visibilityPhones: Array.isArray(d.visibilityPhones)
? (d.visibilityPhones as string[]) ? (d.visibilityPhones as string[])
: [], : [],
isTest: !!d.isTest,
sortOrder: d.sortOrder != null ? Number(d.sortOrder) : 0,
}); });
setDrawerOpen(true); setDrawerOpen(true);
} }
@@ -613,7 +452,14 @@ export default function StoresPage() {
const payload = { const payload = {
name: v.name, name: v.name,
phone: v.phone, phone: v.phone,
coverUrl: v.coverUrl, contactPhone: String(v.contactPhone || '').trim() || v.phone,
coverUrl: v.coverUrl ?? '',
envPhotoUrls: Array.isArray(v.envPhotoUrls)
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
contractUrls: Array.isArray(v.contractUrls)
? v.contractUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
intro: v.intro, intro: v.intro,
benefitUsageRule: benefitUsageRule:
typeof v.benefitUsageRule === 'string' && typeof v.benefitUsageRule === 'string' &&
@@ -636,9 +482,8 @@ export default function StoresPage() {
bankAccountNo: v.bankAccountNo ?? null, bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null, bankBranch: v.bankBranch ?? null,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? []) isTest: !!v.isTest,
.map((p) => String(p || '').replace(/\D/g, '').trim()) sortOrder: v.sortOrder != null ? Number(v.sortOrder) : 0,
.filter(Boolean),
...(hasCoords ...(hasCoords
? { latitude: Number(v.latitude), longitude: Number(v.longitude) } ? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
: {}), : {}),
@@ -647,7 +492,10 @@ export default function StoresPage() {
method: 'PUT', method: 'PUT',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
message.success('门店信息已保存'); const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true });
message.success(
packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存',
);
setDetail(updated); setDetail(updated);
setPhoneMismatch(null); setPhoneMismatch(null);
void reload(); void reload();
@@ -721,8 +569,9 @@ export default function StoresPage() {
function openCreateModal() { function openCreateModal() {
void loadOptions(); void loadOptions();
createForm.setFieldsValue({ createForm.setFieldsValue({
envPhotoUrls: ['', '', ''], envPhotoUrls: [],
settlementRate: 60, settlementRate: 60,
sortOrder: 0,
openTime: '10:00', openTime: '10:00',
closeTime: '22:00', closeTime: '22:00',
openTime2: undefined, openTime2: undefined,
@@ -773,6 +622,7 @@ export default function StoresPage() {
} }
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[]; const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
const contractUrls = (values.contractUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
await request('/admin/stores', { await request('/admin/stores', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -783,6 +633,7 @@ export default function StoresPage() {
city: values.city, city: values.city,
name: values.name.trim(), name: values.name.trim(),
phone: values.phone.trim(), phone: values.phone.trim(),
contactPhone: String(values.contactPhone || values.phone || '').trim(),
district: values.district.trim(), district: values.district.trim(),
address: values.address.trim(), address: values.address.trim(),
...(values.latitude != null && ...(values.latitude != null &&
@@ -806,15 +657,13 @@ export default function StoresPage() {
: {}), : {}),
coverUrl: values.coverUrl?.trim() || undefined, coverUrl: values.coverUrl?.trim() || undefined,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: values.contractUrl?.trim() || undefined, contractUrls: contractUrls.length ? contractUrls : undefined,
bankAccountName: values.bankAccountName.trim(), bankAccountName: values.bankAccountName.trim(),
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(), bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100, settlementRate: Number(values.settlementRate ?? 60) / 100,
sortOrder: values.sortOrder != null ? Number(values.sortOrder) : 0,
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
visibilityPhones: (values.visibilityPhones ?? [])
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
}), }),
}); });
message.success('门店已创建'); message.success('门店已创建');
@@ -838,14 +687,37 @@ export default function StoresPage() {
title: '封面', dataIndex: 'coverUrl', width: 72, title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—', render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
}, },
{ title: '门店名', dataIndex: 'name', width: 140 }, {
title: '门店名',
dataIndex: 'name',
width: 180,
ellipsis: { showTitle: false },
render: (v: string, row) => {
const name = v || '—';
return (
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
{name}
</Typography.Text>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
);
},
},
{ {
title: '分类', title: '分类',
width: 100, width: 100,
ellipsis: true,
render: (_, row) => row.category?.name || '—', render: (_, row) => row.category?.name || '—',
}, },
{ title: '城市', dataIndex: 'cityName', width: 80 }, { title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
{ title: '电话', dataIndex: 'phone', width: 120 }, { title: '登录号', dataIndex: 'phone', width: 120 },
{
title: '联系电话',
dataIndex: 'contactPhone',
width: 120,
render: (v: string | null | undefined, row) => v || row.phone,
},
{ {
title: '营业状态', dataIndex: 'status', width: 90, title: '营业状态', dataIndex: 'status', width: 90,
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
@@ -856,10 +728,10 @@ export default function StoresPage() {
const status = s || 'APPROVED'; const status = s || 'APPROVED';
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green'; const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
return ( return (
<Space direction="vertical" size={0}> <Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag> <Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? ( {status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis> <Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
{row.rejectReason} {row.rejectReason}
</Typography.Text> </Typography.Text>
) : null} ) : null}
@@ -867,23 +739,58 @@ export default function StoresPage() {
); );
}, },
}, },
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 }, {
title: '开城合伙人',
dataIndex: 'partner',
width: 140,
ellipsis: { showTitle: false },
render: (partner: StoreRow['partner']) => {
if (!partner) return '—';
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
return (
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
{label}
</Typography.Text>
);
},
},
{ {
title: '可见', title: '可见',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
width: 90, width: 90,
render: (v, row) => render: (v) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>, v ? <Tag color="orange"></Tag> : <Tag></Tag>,
}, },
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' }, { title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' }, { title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 140, title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button> <Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}></Button> <Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}></Button>
{row.pendingPackageAuditId ? (
<>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
>
</Button>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
>
</Button>
</>
) : null}
</Space> </Space>
), ),
}, },
@@ -932,6 +839,9 @@ export default function StoresPage() {
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item> <Form.Item>
<Button <Button
@@ -948,8 +858,24 @@ export default function StoresPage() {
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} <Table
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1720 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
<Space wrap> <Space wrap>
@@ -1046,18 +972,34 @@ export default function StoresPage() {
type="warning" type="warning"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message={`登录账号手机号仍${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录`} message={`主账号登录号${phoneMismatch},与门店登录字段不一致。保存「登录手机号」将同步到门店端登录账号`}
/> />
) : null} ) : null}
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item <Form.Item
name="phone" name="phone"
label="登录手机号" label="登录手机号(老板)"
rules={[{ required: true }]} rules={[{ required: true }]}
extra="门店端短信登录使用此号码;修改后需用新号重新登录" extra="门店端主账号短信登录;修改后需用新号重新登录"
> >
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
rules={[
{ required: true, message: '请填写对外联系电话' },
{
validator: (_, value) =>
isStoreContactPhone(String(value || ''))
? Promise.resolve()
: Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)),
},
]}
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机"
>
<Input placeholder="手机号或座机,如 0379-8888888" />
</Form.Item>
<Form.Item <Form.Item
name="categoryParentId" name="categoryParentId"
label="门店分类(大类)" label="门店分类(大类)"
@@ -1152,6 +1094,13 @@ export default function StoresPage() {
<Form.Item name="avgPrice" label="人均费用(选填)"> <Form.Item name="avgPrice" label="人均费用(选填)">
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" /> <InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
</Form.Item> </Form.Item>
<Form.Item
name="sortOrder"
label="排序"
extra="数值越小越靠前;同排序时按距离(有定位)或创建时间"
>
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
</Form.Item>
<Space wrap style={{ width: '100%' }}> <Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}> <Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
<Input type="time" style={{ width: 140 }} /> <Input type="time" style={{ width: 140 }} />
@@ -1169,6 +1118,14 @@ export default function StoresPage() {
</Form.Item> </Form.Item>
</Space> </Space>
<StoreVisibilityWhitelistFields form={editForm} /> <StoreVisibilityWhitelistFields form={editForm} />
<Form.Item
name="isTest"
label="测试门店"
valuePropName="checked"
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</> </>
), ),
}, },
@@ -1213,12 +1170,14 @@ export default function StoresPage() {
{ {
key: 'media', key: 'media',
label: '审核材料', label: '审核材料',
children: <StoreAuditMediaSection detail={detail} />, forceRender: true,
children: <StoreAuditMediaEditor />,
}, },
{ {
key: 'packages', key: 'packages',
label: '套餐', label: '套餐',
children: <AdminStorePackagesSection storeId={String(detail.id)} />, forceRender: true,
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
}, },
]} ]}
/> />
@@ -1316,8 +1275,27 @@ export default function StoresPage() {
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}> <Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
<Input placeholder="请输入门店名称" /> <Input placeholder="请输入门店名称" />
</Form.Item> </Form.Item>
<Form.Item name="phone" label="门店手机号(登录账号" rules={[{ required: true, message: '请填写门店手机号' }]}> <Form.Item name="phone" label="登录手机号(老板" rules={[{ required: true, message: '请填写登录手机号' }]}>
<Input placeholder="11位手机号" /> <Input placeholder="门店端主账号登录" />
</Form.Item>
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
extra="用户端拨号展示;留空则与登录号相同。支持座机"
rules={[
{
validator: (_, value) => {
const raw = String(value || '').trim();
if (!raw || isStoreContactPhone(raw)) return Promise.resolve();
return Promise.reject(new Error(STORE_CONTACT_PHONE_HINT));
},
},
]}
>
<Input placeholder="手机号或座机,如 0379-8888888" />
</Form.Item>
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
</Form.Item> </Form.Item>
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}> <Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
<Input.TextArea rows={2} placeholder="请输入详细门牌号" /> <Input.TextArea rows={2} placeholder="请输入详细门牌号" />
@@ -1403,26 +1381,31 @@ export default function StoresPage() {
<Form.Item name="coverUrl" label="门头照"> <Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" /> <OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item> </Form.Item>
<Typography.Text strong></Typography.Text> <Form.Item
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}> name="envPhotoUrls"
3 label="环境照片"
</Typography.Paragraph> extra="选填;支持批量上传,最多 20 张"
<Form.List name="envPhotoUrls"> >
{(fields, { add }) => ( <MultiImageUpload
<div style={{ marginTop: 8 }}> bizType="STORE_ENV"
{fields.map((field, index) => ( mediaType="IMAGE"
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}> maxCount={20}
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" /> tip="环境照支持一次选择多张批量上传"
</Form.Item> />
))} </Form.Item>
<Button type="dashed" onClick={() => add('')} block> <Form.Item
name="contractUrls"
</Button> label="签约合同"
</div> extra="选填;支持多张合同照片或 PDF,最多 20 个"
)} >
</Form.List> <MultiImageUpload
<Form.Item name="contractUrl" label="签约合同"> bizType="STORE_CONTRACT"
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" /> mediaType="FILE"
accept="image/*,.pdf"
maxCount={20}
buttonText="批量上传合同"
tip="合同支持一次选择多张照片批量上传,最多 20 个"
/>
</Form.Item> </Form.Item>
</div> </div>
<div style={{ display: createStep === 2 ? 'block' : 'none' }}> <div style={{ display: createStep === 2 ? 'block' : 'none' }}>
+397 -16
View File
@@ -2,8 +2,10 @@ import { useEffect, useState } from 'react';
import { import {
Button, Button,
Card, Card,
Checkbox,
Drawer, Drawer,
Form, Form,
Image,
Input, Input,
Modal, Modal,
Radio, Radio,
@@ -28,18 +30,25 @@ import {
DEV_PLAN_TASK_TYPE_LABELS, DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS, SUPPORT_TICKET_STATUS_LABELS,
SUPPORT_TICKET_TYPE_LABELS, SUPPORT_TICKET_TYPE_LABELS,
SUPPORT_TICKET_PRIORITY_LABELS,
mapSupportTicketTypeToDevPlanTask, mapSupportTicketTypeToDevPlanTask,
type BatchReviewPreviewItem, type BatchReviewPreviewItem,
type BatchReviewPreviewResponse, type BatchReviewPreviewResponse,
type DevPlanTaskTypeDto, type DevPlanTaskTypeDto,
type DevPlanVersionDto,
type SupportTicketDto, type SupportTicketDto,
type SupportTicketLinkedTaskDto, type SupportTicketLinkedTaskDto,
type SupportTicketPriorityDto,
type SupportTicketStatusDto, type SupportTicketStatusDto,
type SupportTicketTypeDto, type SupportTicketTypeDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
const STATUS_COLOR: Record<SupportTicketStatusDto, string> = { const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
PENDING_REVIEW: 'orange', PENDING_REVIEW: 'orange',
@@ -47,6 +56,7 @@ const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
DEVELOPING: 'blue', DEVELOPING: 'blue',
TESTING: 'purple', TESTING: 'purple',
PASSED: 'green', PASSED: 'green',
PUBLISHED: 'cyan',
}; };
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map( const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
@@ -57,6 +67,17 @@ const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTick
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }), (value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
); );
const PRIORITY_COLOR: Record<SupportTicketPriorityDto, string> = {
LOW: 'default',
NORMAL: 'blue',
HIGH: 'orange',
URGENT: 'red',
};
const PRIORITY_OPTIONS = (Object.keys(SUPPORT_TICKET_PRIORITY_LABELS) as SupportTicketPriorityDto[]).map(
(value) => ({ value, label: SUPPORT_TICKET_PRIORITY_LABELS[value] }),
);
const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map( const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map(
(v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }), (v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }),
); );
@@ -73,6 +94,7 @@ export default function SupportTicketsPage() {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.ticketType) qs.set('ticketType', filters.ticketType); if (filters.ticketType) qs.set('ticketType', filters.ticketType);
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.priority) qs.set('priority', filters.priority);
return qs; return qs;
}, },
[filters], [filters],
@@ -88,15 +110,45 @@ export default function SupportTicketsPage() {
const [batchPreviewOpen, setBatchPreviewOpen] = useState(false); const [batchPreviewOpen, setBatchPreviewOpen] = useState(false);
const [batchPreview, setBatchPreview] = useState<BatchReviewPreviewItem[]>([]); const [batchPreview, setBatchPreview] = useState<BatchReviewPreviewItem[]>([]);
const [batchConfirming, setBatchConfirming] = useState(false); const [batchConfirming, setBatchConfirming] = useState(false);
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
const [batchStatusSaving, setBatchStatusSaving] = useState(false);
const [batchCreateSaving, setBatchCreateSaving] = useState(false);
const [batchPublishOpen, setBatchPublishOpen] = useState(false);
const [batchPublishSaving, setBatchPublishSaving] = useState(false);
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
const [editOpen, setEditOpen] = useState(false);
const [editSaving, setEditSaving] = useState(false);
const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE'); const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE');
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [reviewForm] = Form.useForm<{ decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string }>(); const [reviewForm] = Form.useForm<{ decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string }>();
const [tasksForm] = Form.useForm<{ tasks: Array<{ content: string; type: DevPlanTaskTypeDto }> }>(); const [tasksForm] = Form.useForm<{
tasks: Array<{ content: string; type: DevPlanTaskTypeDto }>;
dispatchToWecom?: boolean;
dispatchSupplement?: string;
}>();
const [batchForm] = Form.useForm<{ items: BatchReviewPreviewItem[] }>(); const [batchForm] = Form.useForm<{ items: BatchReviewPreviewItem[] }>();
const [batchStatusForm] = Form.useForm<{
status: SupportTicketStatusDto;
rejectReason?: string;
note?: string;
}>();
const [batchPublishForm] = Form.useForm<{
versionId: string;
dispatchToWecom?: boolean;
dispatchSupplement?: string;
}>();
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
if (!batchPublishOpen) return;
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
.then((res) => setVersions(res.items ?? []))
.catch(() => setVersions([]));
}, [batchPublishOpen]);
useEffect(() => { useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
@@ -117,6 +169,8 @@ export default function SupportTicketsPage() {
title: values.title.trim(), title: values.title.trim(),
content: values.content?.trim() || undefined, content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
priority: values.priority,
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
}), }),
}); });
message.success('技术支持工单已创建,等待最高管理员评审'); message.success('技术支持工单已创建,等待最高管理员评审');
@@ -166,13 +220,143 @@ export default function SupportTicketsPage() {
const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500); const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500);
tasksForm.setFieldsValue({ tasksForm.setFieldsValue({
tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }], tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }],
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
}); });
setCreateTasksOpen(true); setCreateTasksOpen(true);
} }
function openEdit() {
if (!detail) return;
editForm.setFieldsValue({
ticketType: detail.ticketType,
title: detail.title,
content: detail.content || '',
remark: detail.remark || '',
priority: detail.priority ?? 'NORMAL',
attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''],
});
setEditOpen(true);
}
async function submitEdit() {
const values = await editForm.validateFields();
setEditSaving(true);
try {
await request(`/admin/support-tickets/${detail!.id}`, {
method: 'PATCH',
body: JSON.stringify({
ticketType: values.ticketType,
title: values.title.trim(),
content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined,
priority: values.priority,
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
}),
});
message.success('已保存');
setEditOpen(false);
await openDetail(String(detail!.id));
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setEditSaving(false);
}
}
async function submitBatchStatus() {
const values = await batchStatusForm.validateFields();
setBatchStatusSaving(true);
try {
const res = await request<{ successCount: number; failCount: number }>(
'/admin/support-tickets/batch-update-status',
{
method: 'POST',
body: JSON.stringify({
ticketIds: selectedRowKeys,
status: values.status,
rejectReason: values.rejectReason?.trim() || undefined,
note: values.note?.trim() || undefined,
}),
},
);
message.success(`成功 ${res.successCount} 条,失败 ${res.failCount}`);
setBatchStatusOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量更新失败');
} finally {
setBatchStatusSaving(false);
}
}
async function submitBatchCreateTasks() {
if (!selectedRowKeys.length) return;
setBatchCreateSaving(true);
try {
const res = await request<{ successCount: number; failCount: number }>(
'/admin/support-tickets/batch-create-tasks',
{
method: 'POST',
body: JSON.stringify({ ticketIds: selectedRowKeys }),
},
);
message.success(`已创建 ${res.successCount} 条任务,跳过/失败 ${res.failCount}`);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量创建任务失败');
} finally {
setBatchCreateSaving(false);
}
}
function openBatchPublish() {
batchPublishForm.setFieldsValue({
versionId: undefined,
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
});
setBatchPublishOpen(true);
}
async function submitBatchPublish() {
const values = await batchPublishForm.validateFields();
localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false');
setBatchPublishSaving(true);
try {
const res = await request<{
successCount: number;
failCount: number;
linkedTaskCount: number;
}>('/admin/support-tickets/batch-publish', {
method: 'POST',
body: JSON.stringify({
ticketIds: selectedRowKeys,
versionId: values.versionId,
dispatchToWecom: !!values.dispatchToWecom,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}),
});
message.success(
`已关联 ${res.linkedTaskCount} 条任务到版本,工单成功 ${res.successCount} 条,失败 ${res.failCount}`,
);
setBatchPublishOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量发布失败');
} finally {
setBatchPublishSaving(false);
}
}
async function submitCreateTasks() { async function submitCreateTasks() {
const values = await tasksForm.validateFields(); const values = await tasksForm.validateFields();
const note = reviewForm.getFieldValue('note') as string | undefined; const note = reviewForm.getFieldValue('note') as string | undefined;
localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false');
setActing(true); setActing(true);
try { try {
await request(`/admin/support-tickets/${detail!.id}/review`, { await request(`/admin/support-tickets/${detail!.id}/review`, {
@@ -181,9 +365,11 @@ export default function SupportTicketsPage() {
decision: 'APPROVE', decision: 'APPROVE',
note: note?.trim() || undefined, note: note?.trim() || undefined,
tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })), tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })),
dispatchToWecom: !!values.dispatchToWecom,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}), }),
}); });
message.success('审批通过,已创建开发任务'); message.success(values.dispatchToWecom ? '审批通过,已创建任务并派发企微' : '审批通过,已创建开发任务');
setCreateTasksOpen(false); setCreateTasksOpen(false);
setDrawerOpen(false); setDrawerOpen(false);
reload(); reload();
@@ -283,12 +469,29 @@ export default function SupportTicketsPage() {
width: 90, width: 90,
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t, render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
}, },
{
title: '优先级',
dataIndex: 'priority',
width: 90,
render: (p: SupportTicketPriorityDto) => (
<Tag color={PRIORITY_COLOR[p] ?? 'default'}>
{SUPPORT_TICKET_PRIORITY_LABELS[p] ?? p ?? '普通'}
</Tag>
),
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 100,
render: (s: SupportTicketStatusDto) => ( render: (s: SupportTicketStatusDto, row) => (
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag> <Space size={4} direction="vertical" style={{ lineHeight: 1.2 }}>
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
{row.releasedVersionNo ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{row.releasedVersionNo}
</Typography.Text>
) : null}
</Space>
), ),
}, },
{ title: '标题', dataIndex: 'title', ellipsis: true }, { title: '标题', dataIndex: 'title', ellipsis: true },
@@ -309,9 +512,12 @@ export default function SupportTicketsPage() {
if (!detail) return null; if (!detail) return null;
if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) { if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) {
return ( return (
<Button type="primary" loading={acting} onClick={openReview}> <Space>
<Button onClick={openEdit}></Button>
</Button> <Button type="primary" loading={acting} onClick={openReview}>
</Button>
</Space>
); );
} }
if (detail.status === 'DEVELOPING') { if (detail.status === 'DEVELOPING') {
@@ -346,9 +552,30 @@ export default function SupportTicketsPage() {
</Typography.Title> </Typography.Title>
<Space> <Space>
{isSuperAdmin ? ( {isSuperAdmin ? (
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}> <>
<Button
</Button> disabled={!selectedRowKeys.length}
loading={batchCreateSaving}
onClick={() => void submitBatchCreateTasks()}
>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
</Button>
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
</Button>
<Button
disabled={!selectedRowKeys.length}
onClick={() => {
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
setBatchStatusOpen(true);
}}
>
</Button>
</>
) : null} ) : null}
<Button type="primary" onClick={() => setCreateOpen(true)}> <Button type="primary" onClick={() => setCreateOpen(true)}>
@@ -370,6 +597,9 @@ export default function SupportTicketsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} /> <Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级">
<Select allowClear style={{ width: 120 }} options={PRIORITY_OPTIONS} />
</Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
</Button> </Button>
@@ -385,11 +615,7 @@ export default function SupportTicketsPage() {
isSuperAdmin isSuperAdmin
? { ? {
selectedRowKeys, selectedRowKeys,
onChange: (keys, rows) => { onChange: (keys) => setSelectedRowKeys(keys as string[]),
const pending = rows.filter((r) => r.status === 'PENDING_REVIEW').map((r) => String(r.id));
setSelectedRowKeys(pending.length === rows.length ? (keys as string[]) : pending);
},
getCheckboxProps: (row) => ({ disabled: row.status !== 'PENDING_REVIEW' }),
} }
: undefined : undefined
} }
@@ -415,6 +641,9 @@ export default function SupportTicketsPage() {
{detail.ticketNo} {detail.ticketNo}
</Typography.Text> </Typography.Text>
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag> <Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
<Tag color={PRIORITY_COLOR[detail.priority ?? 'NORMAL']}>
{SUPPORT_TICKET_PRIORITY_LABELS[detail.priority ?? 'NORMAL']}
</Tag>
</> </>
)} )}
</Space> </Space>
@@ -445,6 +674,11 @@ export default function SupportTicketsPage() {
{detail.status === 'PASSED' && ( {detail.status === 'PASSED' && (
<Typography.Text type="success"></Typography.Text> <Typography.Text type="success"></Typography.Text>
)} )}
{detail.status === 'PUBLISHED' && (
<Typography.Text type="success">
{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''}
</Typography.Text>
)}
</Space> </Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(detail.createdAt)} {fmtTime(detail.createdAt)}
@@ -468,6 +702,7 @@ export default function SupportTicketsPage() {
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt }, { key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null }, { key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt }, { key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
{ key: 'PUBLISHED' as SupportTicketStatusDto, label: '已发布', time: detail.publishedAt },
] ]
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]), : [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]),
]; ];
@@ -612,6 +847,18 @@ export default function SupportTicketsPage() {
</div> </div>
</div> </div>
)} )}
{detail.attachmentUrls?.length ? (
<div style={{ marginTop: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
</Typography.Text>
<Space wrap style={{ marginTop: 8 }}>
{detail.attachmentUrls.map((url) => (
<Image key={url} src={url} width={80} height={80} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</div>
) : null}
</Card> </Card>
{/* 关联开发任务 */} {/* 关联开发任务 */}
@@ -661,10 +908,13 @@ export default function SupportTicketsPage() {
</Drawer> </Drawer>
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交"> <Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG' }}> <Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', priority: 'NORMAL', attachmentUrls: [''] }}>
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}> <Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}> <Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount /> <Input maxLength={128} showCount />
</Form.Item> </Form.Item>
@@ -674,6 +924,27 @@ export default function SupportTicketsPage() {
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount /> <Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item> </Form.Item>
<Form.Item label="附件图片">
<Form.List name="attachmentUrls">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 ? (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
@@ -725,6 +996,116 @@ export default function SupportTicketsPage() {
</> </>
)} )}
</Form.List> </Form.List>
<Form.Item name="dispatchToWecom" valuePropName="checked" style={{ marginTop: 16 }}>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="dispatchSupplement" label="派发补充说明">
<Input.TextArea rows={2} placeholder={DEFAULT_DISPATCH_SUPPLEMENT} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑工单"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => void submitEdit()}
confirmLoading={editSaving}
destroyOnClose
width={640}
>
<Form form={editForm} layout="vertical">
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount />
</Form.Item>
<Form.Item name="content" label="详细说明">
<Input.TextArea rows={5} maxLength={4000} showCount />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
<Form.Item label="附件图片">
<Form.List name="attachmentUrls">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 ? (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form>
</Modal>
<Modal
title="批量改状态"
open={batchStatusOpen}
onCancel={() => setBatchStatusOpen(false)}
onOk={() => void submitBatchStatus()}
confirmLoading={batchStatusSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary"> {selectedRowKeys.length} </Typography.Paragraph>
<Form form={batchStatusForm} layout="vertical">
<Form.Item name="status" label="目标状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS.filter((o) => o.value !== 'PENDING_REVIEW')} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.status !== cur.status}>
{({ getFieldValue }) =>
getFieldValue('status') === 'REJECTED' ? (
<Form.Item name="rejectReason" label="驳回理由" rules={[{ required: true }]}>
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title="一键发布"
open={batchPublishOpen}
onCancel={() => setBatchPublishOpen(false)}
onOk={() => void submitBatchPublish()}
confirmLoading={batchPublishSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary">
{selectedRowKeys.length}
</Typography.Paragraph>
<Form form={batchPublishForm} layout="vertical">
<Form.Item name="versionId" label="开发版本" rules={[{ required: true, message: '请选择开发版本' }]}>
<Select
placeholder="选择版本"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
<Form.Item name="dispatchToWecom" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="dispatchSupplement" label="派发补充说明">
<Input.TextArea rows={2} placeholder={DEFAULT_DISPATCH_SUPPLEMENT} />
</Form.Item>
</Form> </Form>
</Modal> </Modal>
@@ -0,0 +1,687 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Card,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
type PhoneRow = {
id: string;
phone: string;
note: string | null;
createdByHqId: string | null;
createdAt: string;
updatedAt: string;
};
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
type LinkedPayload = {
phone: PhoneRow;
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
};
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
{ value: 'user', label: 'C 端用户' },
{ value: 'store_account', label: '门店账号' },
{ value: 'partner', label: '合伙人' },
{ value: 'store', label: '门店' },
{ value: 'order', label: '订单' },
];
export default function TestWhitelistPage() {
const [mockSms, setMockSms] = useState(false);
const [mockWechat, setMockWechat] = useState(false);
const [mockPay, setMockPay] = useState(false);
const [mockLoading, setMockLoading] = useState(true);
const [mockSaving, setMockSaving] = useState(false);
const [phoneForm] = Form.useForm();
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
const [phonesLoading, setPhonesLoading] = useState(false);
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
const [addOpen, setAddOpen] = useState(false);
const [addForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
const [editForm] = Form.useForm();
const [migrating, setMigrating] = useState(false);
const [accountType, setAccountType] = useState<AccountType>('user');
const [accountPhone, setAccountPhone] = useState('');
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
const [accountsLoading, setAccountsLoading] = useState(false);
const [accountPage, setAccountPage] = useState(1);
const [accountPageSize, setAccountPageSize] = useState(20);
const [linkedOpen, setLinkedOpen] = useState(false);
const [linkedLoading, setLinkedLoading] = useState(false);
const [linked, setLinked] = useState<LinkedPayload | null>(null);
async function loadMockFlags() {
setMockLoading(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
} finally {
setMockLoading(false);
}
}
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
setMockSaving(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
{
method: 'PUT',
body: JSON.stringify(body),
},
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
message.success('已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
await loadMockFlags();
} finally {
setMockSaving(false);
}
}
const loadPhones = useCallback(async () => {
setPhonesLoading(true);
try {
const qs = new URLSearchParams({
page: String(phonePage),
pageSize: String(phonePageSize),
});
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
setPhones(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
} finally {
setPhonesLoading(false);
}
}, [phonePage, phonePageSize, phoneFilters]);
const loadAccounts = useCallback(async () => {
setAccountsLoading(true);
try {
const qs = new URLSearchParams({
type: accountType,
page: String(accountPage),
pageSize: String(accountPageSize),
});
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
const res = await request<Paginated<Record<string, unknown>>>(
`/admin/test-whitelist/accounts?${qs}`,
);
setAccounts(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载测试账号失败');
} finally {
setAccountsLoading(false);
}
}, [accountType, accountPage, accountPageSize, accountPhone]);
useEffect(() => {
void loadMockFlags();
}, []);
useEffect(() => {
void loadPhones();
}, [loadPhones]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
async function onAddPhone() {
const v = await addForm.validateFields();
try {
await request('/admin/test-whitelist/phones', {
method: 'POST',
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
});
message.success('已添加');
setAddOpen(false);
addForm.resetFields();
setPhonePage(1);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败');
}
}
async function onEditPhone() {
if (!editRow) return;
const v = await editForm.validateFields();
try {
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
method: 'PATCH',
body: JSON.stringify({ note: v.note ?? null }),
});
message.success('已更新');
setEditOpen(false);
setEditRow(null);
void loadPhones();
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败');
}
}
async function onDeletePhone(id: string) {
try {
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
message.success('已删除');
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function onMigrate() {
setMigrating(true);
try {
const res = await request<{ importedCandidates: number; added: number }>(
'/admin/test-whitelist/migrate-visibility',
{ method: 'POST' },
);
message.success(
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added}`,
);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败');
} finally {
setMigrating(false);
}
}
async function openLinked(row: PhoneRow) {
setLinkedOpen(true);
setLinkedLoading(true);
setLinked(null);
try {
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
setLinked(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载关联失败');
setLinkedOpen(false);
} finally {
setLinkedLoading(false);
}
}
const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 },
{
title: '备注',
dataIndex: 'note',
ellipsis: true,
render: (v) => v || '—',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: fmtTime,
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openLinked(row)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
</Button>
<Popconfirm
title="确认移出白名单?"
description="将同步清除该手机号关联账号的测试标记"
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void onDeletePhone(row.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
function accountColumns(): ColumnsType<Record<string, unknown>> {
if (accountType === 'user') {
return [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store_account') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'partner') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store') {
return [
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{ title: '状态', dataIndex: 'status', width: 110 },
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => `¥${v}`,
},
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户手机',
width: 120,
render: (_, row) =>
(row.user as { phone?: string | null } | undefined)?.phone || '—',
},
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Mock =
</Typography.Text>
<Space wrap>
<Checkbox
checked={mockSms}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockWechat}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockPay}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
>
</Checkbox>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
{MOCK_KEYS.join(' / ')}
</Typography.Text>
</Card>
<Tabs
items={[
{
key: 'phones',
label: '手机号名单',
children: (
<>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
<Form
form={phoneForm}
layout="inline"
onFinish={(v) => {
setPhoneFilters({ phone: v.phone || undefined });
setPhonePage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊搜索" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Space>
<Button
onClick={() => {
Modal.confirm({
title: '从可见性白名单导入',
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
okText: '开始导入',
cancelText: '取消',
onOk: () => onMigrate(),
});
}}
loading={migrating}
>
</Button>
<Button type="primary" onClick={() => setAddOpen(true)}>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={phonesLoading}
columns={phoneColumns}
dataSource={phones?.items ?? []}
pagination={{
current: phonePage,
pageSize: phonePageSize,
total: phones?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPhonePage(p);
setPhonePageSize(ps);
},
}}
/>
</>
),
},
{
key: 'accounts',
label: '测试账号记录',
children: (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Select
style={{ width: 140 }}
value={accountType}
options={ACCOUNT_TYPE_OPTIONS}
onChange={(v: AccountType) => {
setAccountType(v);
setAccountPage(1);
}}
/>
<Input
allowClear
placeholder="按手机号筛选"
style={{ width: 160 }}
value={accountPhone}
onChange={(e) => setAccountPhone(e.target.value)}
onPressEnter={() => {
setAccountPage(1);
void loadAccounts();
}}
/>
<Button
type="primary"
onClick={() => {
if (accountPage !== 1) setAccountPage(1);
else void loadAccounts();
}}
>
</Button>
</Space>
<Table
rowKey="id"
loading={accountsLoading}
columns={accountColumns()}
dataSource={accounts?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: accountPage,
pageSize: accountPageSize,
total: accounts?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setAccountPage(p);
setAccountPageSize(ps);
},
}}
/>
</>
),
},
]}
/>
<Modal
title="添加白名单手机号"
open={addOpen}
onCancel={() => setAddOpen(false)}
onOk={() => void onAddPhone()}
destroyOnClose
>
<Form form={addForm} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
]}
>
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title={`编辑备注 · ${editRow?.phone ?? ''}`}
open={editOpen}
onCancel={() => {
setEditOpen(false);
setEditRow(null);
}}
onOk={() => void onEditPhone()}
destroyOnClose
>
<Form form={editForm} layout="vertical">
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Drawer
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
open={linkedOpen}
onClose={() => setLinkedOpen(false)}
width={560}
destroyOnClose
>
{linkedLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : linked ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
</Descriptions>
<div>
<Typography.Title level={5}>C {linked.users.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.users}
columns={[
{ title: '编号', dataIndex: 'userNo' },
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.storeAccounts.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.storeAccounts}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.partners.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.partners}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.stores.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.stores}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
</Space>
) : null}
</Drawer>
</div>
);
}
+37 -4
View File
@@ -10,11 +10,18 @@ import {
Select, Select,
Space, Space,
Table, Table,
Tag,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types'; import {
TICKET_STATUS_LABELS,
TICKET_TYPE_LABELS,
ticketStatusLabel,
type TicketStatusDto,
type TicketTypeDto,
} from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -31,6 +38,21 @@ type Row = {
createdAt: 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() { export default function TicketsPage() {
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -101,7 +123,14 @@ export default function TicketsPage() {
width: 110, width: 110,
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t, render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
}, },
{ title: '状态', dataIndex: 'status', width: 90 }, {
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: string) => (
<Tag color={STATUS_COLOR[s as TicketStatusDto] ?? 'default'}>{ticketStatusLabel(s)}</Tag>
),
},
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -165,7 +194,7 @@ export default function TicketsPage() {
/> />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Input allowClear placeholder="PENDING" /> <Select allowClear style={{ width: 140 }} placeholder="全部" options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -212,7 +241,11 @@ export default function TicketsPage() {
<Descriptions.Item label="类型"> <Descriptions.Item label="类型">
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType} {TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item> <Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status as TicketStatusDto] ?? 'default'}>
{ticketStatusLabel(detail.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="关联"> <Descriptions.Item label="关联">
{String(detail.refType)} #{String(detail.refId)} {String(detail.refType)} #{String(detail.refId)}
</Descriptions.Item> </Descriptions.Item>
+68 -2
View File
@@ -30,6 +30,13 @@ type UserOrderRow = {
createdAt: string; createdAt: string;
}; };
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
function fmtBenefit(v: number | null | undefined) {
const n = Number(v ?? 0);
if (!Number.isFinite(n) || n <= 0) return '—';
return `¥${n.toFixed(2)}`;
}
type UserBehaviorLog = { type UserBehaviorLog = {
id: string; id: string;
eventName: string; eventName: string;
@@ -112,6 +119,7 @@ export default function UsersPage() {
if (values.status !== undefined && values.status !== '') { if (values.status !== undefined && values.status !== '') {
qs.set('status', String(values.status)); qs.set('status', String(values.status));
} }
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`); const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
setData(res); setData(res);
} finally { } finally {
@@ -237,7 +245,17 @@ export default function UsersPage() {
]; ];
const columns: ColumnsType<AdminUserRow> = [ const columns: ColumnsType<AdminUserRow> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 }, {
title: '用户编号',
dataIndex: 'userNo',
width: 140,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '昵称', dataIndex: 'nickname', width: 100 }, { title: '昵称', dataIndex: 'nickname', width: 100 },
{ {
title: '手机', title: '手机',
@@ -304,6 +322,39 @@ export default function UsersPage() {
render: (v) => (v ? <Tag color="blue"></Tag> : '—'), render: (v) => (v ? <Tag color="blue"></Tag> : '—'),
}, },
{ title: '订单数', dataIndex: 'orderCount', width: 80 }, { title: '订单数', dataIndex: 'orderCount', width: 80 },
{
title: '剩余权益',
dataIndex: 'benefitBalance',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0),
render: (v: number | undefined) =>
Number(v ?? 0) > 0 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(v)}
</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '已用权益',
dataIndex: 'benefitUsedAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
render: (v: number | undefined) => (
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
),
},
{
title: '累计权益',
dataIndex: 'benefitTotalAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0),
render: (v: number | undefined) => fmtBenefit(v),
},
{ {
title: '注册时间', title: '注册时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
@@ -362,6 +413,9 @@ export default function UsersPage() {
{ value: 0, label: '停用' }, { value: 0, label: '停用' },
]} /> ]} />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
@@ -375,7 +429,7 @@ export default function UsersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 1830 }}
rowSelection={canDeleteUsers ? { rowSelection={canDeleteUsers ? {
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true, preserveSelectedRowKeys: true,
@@ -447,6 +501,18 @@ export default function UsersPage() {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item> <Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item> <Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
<Descriptions.Item label="好客权益">
<Space size={16} wrap>
<span>
{' '}
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(detail.benefitBalance)}
</Typography.Text>
</span>
<span> {fmtBenefit(detail.benefitUsedAmount)}</span>
<span> {fmtBenefit(detail.benefitTotalAmount)}</span>
</Space>
</Descriptions.Item>
<Descriptions.Item label="注册时间"> <Descriptions.Item label="注册时间">
{new Date(detail.createdAt).toLocaleString('zh-CN')} {new Date(detail.createdAt).toLocaleString('zh-CN')}
</Descriptions.Item> </Descriptions.Item>
+39 -11
View File
@@ -51,13 +51,35 @@ type BillItem = {
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款', UNPAID: '未打款',
PAID: '已打款', PAID: '已打款',
NO_PAYMENT_NEEDED: '无需打款',
}; };
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red', UNPAID: 'red',
PAID: 'green', PAID: 'green',
NO_PAYMENT_NEEDED: 'default',
}; };
function isZeroPayable(amount: number | string | null | undefined) {
return Number(amount ?? 0) === 0;
}
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
function displayWineryStatus(status: string, wineryAmount: number | string) {
if (isZeroPayable(wineryAmount)) {
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
}
return {
key: status,
label: STATUS_LABELS[status] || status,
color: STATUS_COLORS[status] || 'default',
};
}
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
}
const DELIVERY_LABELS: Record<string, string> = { const DELIVERY_LABELS: Record<string, string> = {
LOCAL: '同城', LOCAL: '同城',
CROSS_CITY: '跨城', CROSS_CITY: '跨城',
@@ -212,7 +234,7 @@ export default function WineryBillsPage() {
render: (v) => `${Math.round(Number(v) * 100)}%`, render: (v) => `${Math.round(Number(v) * 100)}%`,
}, },
{ {
title: '酒厂应付', title: '应付',
dataIndex: 'wineryAmount', dataIndex: 'wineryAmount',
width: 110, width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
@@ -220,8 +242,11 @@ export default function WineryBillsPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 90, width: 100,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, render: (s, row) => {
const d = displayWineryStatus(s, row.wineryAmount);
return <Tag color={d.color}>{d.label}</Tag>;
},
}, },
{ {
title: '操作', title: '操作',
@@ -232,7 +257,7 @@ export default function WineryBillsPage() {
<Button type="link" size="small" onClick={() => void openDetail(row.id)}> <Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button> </Button>
{row.status === 'UNPAID' && ( {canConfirmWineryPay(row) && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}> <Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
</Button> </Button>
@@ -258,7 +283,8 @@ export default function WineryBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
8:00 × {ratePct}%绿 T+3 8:00 3 / × {ratePct}%绿 0
</Typography.Text> </Typography.Text>
</Space> </Space>
{canEditWineryBank ? ( {canEditWineryBank ? (
@@ -273,7 +299,7 @@ export default function WineryBillsPage() {
<Space size="large" wrap> <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.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -296,7 +322,7 @@ export default function WineryBillsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 130 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
@@ -347,7 +373,7 @@ export default function WineryBillsPage() {
rowSelection={{ rowSelection={{
selectedRowKeys: selectedKeys, selectedRowKeys: selectedKeys,
onChange: setSelectedKeys, onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }), getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
}} }}
scroll={{ x: 1100 }} scroll={{ x: 1100 }}
pagination={{ pagination={{
@@ -368,8 +394,10 @@ export default function WineryBillsPage() {
<Descriptions column={1} size="small" bordered> <Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item> <Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</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="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item> <Descriptions.Item label="状态">
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item> <Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
</Descriptions> </Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}> <Typography.Title level={5} style={{ marginTop: 16 }}>
@@ -395,7 +423,7 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '酒厂应付', title: '应付',
dataIndex: 'wineryAmount', dataIndex: 'wineryAmount',
width: 90, width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
@@ -24,6 +24,7 @@ import {
import { request } from '../../lib/api'; import { request } from '../../lib/api';
import { fmtTime } from '../../lib/constants'; import { fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
const descLabelStyle: CSSProperties = { const descLabelStyle: CSSProperties = {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -212,6 +213,14 @@ export default function PromoCodeDetailPage() {
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} /> <Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="归因用户数"
value={stats?.attributionCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
@@ -235,6 +244,8 @@ export default function PromoCodeDetailPage() {
</Col> </Col>
</Row> </Row>
<PromoCodeMetricsPanel promoId={detail.id} />
<Modal <Modal
title="编辑推广码" title="编辑推广码"
open={editOpen} 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
@@ -11,7 +11,10 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true "strict": true,
"paths": {
"@dukang/domain": ["../../packages/domain/src/index.ts"]
}
}, },
"include": ["src"] "include": ["src"]
} }
+10 -1
View File
@@ -1,10 +1,19 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
},
},
server: { server: {
host: true,
port: 5175, port: 5175,
proxy: { '/api': 'http://localhost:3000' }, proxy: { '/api': apiTarget },
}, },
}); });
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@dukang/client-logging": "workspace:*", "@dukang/client-logging": "workspace:*",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*", "@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*", "@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*", "@dukang/weixin-sdk": "workspace:*",
@@ -0,0 +1,236 @@
import { useEffect, useRef, useState } from 'react';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
import { enqueueUpload } from '../lib/upload-lock';
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError, toastSuccess } from '../lib/toast';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
bizType: string;
mediaType?: OssMediaType;
maxCount?: number;
disabled?: boolean;
label?: string;
/** 系统文件选择器的 accept,默认仅图片 */
accept?: string;
/** 计量单位文案,如「张」「个」 */
unit?: string;
};
function isCancelError(msg: string): boolean {
return /cancel|取消/i.test(msg);
}
function isPdf(url: string): boolean {
return /\.pdf(\?|$)/i.test(url);
}
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
/** 合伙人端多图批量上传(微信相册可多选) */
export default function MultiOssUploadField({
value,
onChange,
bizType,
mediaType = 'IMAGE',
maxCount = 20,
disabled,
label,
accept = 'image/*',
unit = '张',
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const pickingRef = useRef(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
const onChangeRef = useRef(onChange);
const remaining = Math.max(0, maxCount - urls.length);
const inWechat = isWechatEnv();
useEffect(() => {
urlsRef.current = urls;
}, [urls]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
if (!inWechat) return;
void weixinSdk.init().catch(() => {});
}, [inWechat]);
function showUploadError(text: string) {
setError(text);
toastError(text);
}
async function uploadFiles(files: File[]) {
const current = urlsRef.current;
const room = Math.max(0, maxCount - current.length);
const picked = files.slice(0, room);
if (!picked.length) {
showUploadError(`最多 ${maxCount} ${unit}`);
return;
}
setUploading(true);
setError('');
const appended: string[] = [];
try {
for (const file of picked) {
if (!file.size) continue;
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
appended.push(result.url);
}
if (appended.length) {
const next = [...urlsRef.current, ...appended];
urlsRef.current = next;
onChangeRef.current?.(next);
toastSuccess(`已上传 ${appended.length} ${unit}`);
}
} catch (e) {
showUploadError(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function pickWechat() {
if (pickingRef.current || uploading || disabled || remaining <= 0) return;
pickingRef.current = true;
setError('');
try {
await weixinSdk.init();
const files = await weixinSdk.chooseImages({
count: Math.min(remaining, 9),
sourceType: ['album', 'camera'],
});
if (!files?.length) return;
await uploadFiles(files);
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (isCancelError(msg)) return;
const formatted = formatChooseImageFailMessage(msg) || msg;
showUploadError(formatted);
inputRef.current?.click();
} finally {
pickingRef.current = false;
}
}
function removeAt(index: number) {
if (disabled) return;
onChange?.(urls.filter((_, i) => i !== index));
}
return (
<div className="partner-oss-upload">
<input
ref={inputRef}
type="file"
accept={accept}
multiple
className="partner-oss-upload-input"
disabled={disabled || uploading || remaining <= 0}
onChange={(e) => {
const list = Array.from(e.target.files ?? []);
if (list.length) void uploadFiles(list);
}}
/>
{urls.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: 88,
height: 88,
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
borderRadius: 8,
border: '1px solid rgba(0,0,0,0.08)',
background: '#f7f7f7',
fontSize: 12,
}}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
description
</span>
<span className="text-muted">PDF</span>
</a>
) : (
<img
src={url}
alt=""
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
/>
)}
{!disabled ? (
<button
type="button"
className="partner-packages-remove"
style={{
position: 'absolute',
top: 2,
right: 2,
margin: 0,
padding: '2px 6px',
fontSize: 12,
background: 'rgba(0,0,0,0.55)',
color: '#fff',
borderRadius: 4,
}}
onClick={() => removeAt(index)}
>
</button>
) : null}
</div>
))}
</div>
) : null}
<button
type="button"
className="partner-upload-dashed partner-upload-dashed--compact"
disabled={disabled || uploading || remaining <= 0}
onClick={() => {
if (inWechat) void pickWechat();
else inputRef.current?.click();
}}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
{uploading ? 'hourglass_top' : 'add_a_photo'}
</span>
<span className="text-primary" style={{ fontWeight: 500 }}>
{uploading
? '上传中…'
: remaining <= 0
? `已达上限 ${maxCount}${unit}`
: label ?? `批量上传(${urls.length}/${maxCount}`}
</span>
</button>
{error ? (
<p className="partner-form-error" role="alert">
{error}
</p>
) : null}
</div>
);
}
@@ -19,8 +19,6 @@ type OssUploadFieldProps = {
onWechatReadyChange?: (ready: boolean) => void; onWechatReadyChange?: (ready: boolean) => void;
}; };
const DEFAULT_MAX_MB = 10;
function isCancelError(msg: string): boolean { function isCancelError(msg: string): boolean {
return /cancel|取消/i.test(msg); return /cancel|取消/i.test(msg);
} }
@@ -79,10 +77,6 @@ export default function OssUploadField({
showUploadError('图片读取失败,请重新选择'); showUploadError('图片读取失败,请重新选择');
return; return;
} }
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
return;
}
const result = await uploadFileToOss(file, { bizType, mediaType }); const result = await uploadFileToOss(file, { bizType, mediaType });
onChange?.(result.url); onChange?.(result.url);
} }
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import type { PackageFormItem } from '../lib/storePackages'; import type { PackageFormItem } from '../lib/storePackages';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types'; import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { emptyPackage } from '../lib/storePackages'; import { emptyPackage } from '../lib/storePackages';
import MultiOssUploadField from './MultiOssUploadField';
type Props = { type Props = {
items: PackageFormItem[]; items: PackageFormItem[];
@@ -118,28 +119,46 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
<div className="partner-field"> <div className="partner-field">
<label>使</label> <label>使</label>
<div className="partner-field-input"> <textarea
<span className="material-symbols-outlined">schedule</span> rows={2}
<input placeholder="节假日除外"
placeholder="节假日除外" value={item.usableTime || ''}
value={item.usableTime || ''} disabled={disabled}
disabled={disabled} onChange={(e) => updateAt(index, { usableTime: e.target.value })}
onChange={(e) => updateAt(index, { usableTime: e.target.value })} />
/> </div>
</div>
<div className="partner-field">
<label> {STORE_PACKAGE_IMAGE_MAX_COUNT} </label>
<MultiOssUploadField
bizType="STORE_PACKAGE"
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
disabled={disabled}
value={
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
: item.imageUrl
? [String(item.imageUrl)]
: []
}
onChange={(imageUrls) =>
updateAt(index, {
imageUrls,
imageUrl: imageUrls[0] ?? '',
})
}
/>
</div> </div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<div className="partner-field-input"> <textarea
<span className="material-symbols-outlined">info</span> rows={2}
<input placeholder="不可叠加"
placeholder="不可叠加" value={item.otherNotes || ''}
value={item.otherNotes || ''} disabled={disabled}
disabled={disabled} onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })} />
/>
</div>
</div> </div>
</> </>
) : null} ) : null}
+7
View File
@@ -1,4 +1,5 @@
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types'; import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
import { reportApiError } from '@dukang/client-logging';
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk'; import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
import { showPartnerToast } from './toast'; import { showPartnerToast } from './toast';
@@ -159,6 +160,12 @@ async function rawRequest<T>(
if (json.code !== 0) { if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number }; const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code === 401 ? 401 : json.code; 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; throw err;
} }
return json.data as T; return json.data as T;
+40 -9
View File
@@ -1,3 +1,6 @@
import { getDefaultPartnerRegionForm } from './china-region';
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
export type StoreDraftForm = { export type StoreDraftForm = {
regionCodes: string[]; regionCodes: string[];
cityId: string; cityId: string;
@@ -5,7 +8,10 @@ export type StoreDraftForm = {
city: string; city: string;
district: string; district: string;
name: string; name: string;
/** 门店登录手机号(老板主账号) */
phone: string; phone: string;
/** 对外联系电话(店长);可与登录号不同 */
contactPhone: string;
address: string; address: string;
/** 门店坐标(定位或地理编码) */ /** 门店坐标(定位或地理编码) */
latitude: string; latitude: string;
@@ -25,7 +31,8 @@ export type StoreDraftForm = {
benefitUsageRule: string; benefitUsageRule: string;
coverUrl: string; coverUrl: string;
envPhotoUrls: string[]; envPhotoUrls: string[];
contractUrl: string; /** 签约合同,支持多张照片 / PDF */
contractUrls: string[];
bankAccountName: string; bankAccountName: string;
bankAccountNo: string; bankAccountNo: string;
bankBranch: string; bankBranch: string;
@@ -44,13 +51,12 @@ export function storeDraftKey(accountId?: string): string {
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY; return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
} }
import { getDefaultPartnerRegionForm } from './china-region';
export const defaultStoreForm = (): StoreDraftForm => ({ export const defaultStoreForm = (): StoreDraftForm => ({
...getDefaultPartnerRegionForm(), ...getDefaultPartnerRegionForm(),
cityId: '', cityId: '',
name: '', name: '',
phone: '', phone: '',
contactPhone: '',
address: '', address: '',
latitude: '', latitude: '',
longitude: '', longitude: '',
@@ -66,7 +72,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
benefitUsageRule: '', benefitUsageRule: '',
coverUrl: '', coverUrl: '',
envPhotoUrls: ['', '', ''], envPhotoUrls: ['', '', ''],
contractUrl: '', contractUrls: [],
bankAccountName: '', bankAccountName: '',
bankAccountNo: '', bankAccountNo: '',
bankBranch: '', bankBranch: '',
@@ -84,6 +90,9 @@ function timeToMinutes(hhmm: string): number {
export const MIN_ENV_PHOTO_COUNT = 3; export const MIN_ENV_PHOTO_COUNT = 3;
/** 签约合同最多可上传的照片 / PDF 数量 */
export const MAX_CONTRACT_COUNT = 20;
export function normalizeStringArray(urls: unknown, minLen: number): string[] { export function normalizeStringArray(urls: unknown, minLen: number): string[] {
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : []; const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
while (arr.length < minLen) arr.push(''); while (arr.length < minLen) arr.push('');
@@ -102,6 +111,9 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
...raw, ...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes, regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId), cityId: String(raw.cityId ?? base.cityId),
phone: String(raw.phone ?? base.phone),
contactPhone:
String(raw.contactPhone ?? '').trim() || String(raw.phone ?? '').trim() || base.contactPhone,
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude, latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude, longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
openTime: String(raw.openTime ?? base.openTime), openTime: String(raw.openTime ?? base.openTime),
@@ -111,6 +123,22 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
closeTime2: String(raw.closeTime2 ?? base.closeTime2), closeTime2: String(raw.closeTime2 ?? base.closeTime2),
avgPrice: String(raw.avgPrice ?? base.avgPrice), avgPrice: String(raw.avgPrice ?? base.avgPrice),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT), envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
// 兼容旧草稿:单个 contractUrl 迁移为数组
contractUrls: (() => {
const list = Array.isArray(raw.contractUrls)
? raw.contractUrls
: [(raw as { contractUrl?: unknown }).contractUrl];
const seen = new Set<string>();
const out: string[] = [];
for (const item of list) {
const url = String(item ?? '').trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
if (out.length >= MAX_CONTRACT_COUNT) break;
}
return out;
})(),
packages: Array.isArray(raw.packages) packages: Array.isArray(raw.packages)
? raw.packages.map((p, i) => ({ ? raw.packages.map((p, i) => ({
name: String((p as { name?: string }).name ?? ''), name: String((p as { name?: string }).name ?? ''),
@@ -217,12 +245,13 @@ export function validateStoreStep1(
} }
export function validateStoreStep2( export function validateStoreStep2(
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>, form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrls'>,
): string | null { ): string | null {
if (!form.coverUrl.trim()) return '请上传门头照'; if (!form.coverUrl.trim()) return '请上传门头照';
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length; const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`; if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
if (!form.contractUrl.trim()) return '请上传签约合同'; const contractCount = (form.contractUrls ?? []).filter((u) => u.trim()).length;
if (contractCount < 1) return '请上传签约合同';
return null; return null;
} }
@@ -236,14 +265,16 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
export function validateStoreStep3( export function validateStoreStep3(
form: Pick< form: Pick<
StoreDraftForm, StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' 'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'contactPhone'
>, >,
): string | null { ): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名'; if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号'; if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字'; if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行'; if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话'; if (!form.phone.trim()) return '请填写门店登录手机号';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店登录手机号须为11位手机号';
if (!form.contactPhone.trim()) return '请填写联系电话';
if (!isStoreContactPhone(form.contactPhone.trim())) return STORE_CONTACT_PHONE_HINT;
return null; return null;
} }
+32 -10
View File
@@ -1,5 +1,9 @@
import type { StorePackageItemDto } from '@dukang/shared-types'; import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types'; import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
export type PackageFormItem = StorePackageItemDto; export type PackageFormItem = StorePackageItemDto;
@@ -10,21 +14,36 @@ export function emptyPackage(index = 0): PackageFormItem {
dishes: '', dishes: '',
usableTime: '', usableTime: '',
otherNotes: '', otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index, sortOrder: index,
}; };
} }
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] { export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw return raw
.map((item, index) => ({ .map((item, index) => {
name: item.name.trim(), const imageUrls = normalizeStorePackageImageUrls(item);
price: item.price.trim(), return {
dishes: item.dishes.trim(), name: item.name.trim(),
usableTime: item.usableTime?.trim() || '', price: item.price.trim(),
otherNotes: item.otherNotes?.trim() || '', dishes: item.dishes.trim(),
sortOrder: index, usableTime: item.usableTime?.trim() || '',
})) otherNotes: item.otherNotes?.trim() || '',
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes); imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: index,
};
})
.filter(
(item) =>
item.name ||
item.price ||
item.dishes ||
item.usableTime ||
item.otherNotes ||
item.imageUrls.length > 0,
);
} }
export function validatePackageFormItems(items: PackageFormItem[]): string | null { export function validatePackageFormItems(items: PackageFormItem[]): string | null {
@@ -38,6 +57,9 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
const price = Number(item.price); const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`; if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`; if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
return `${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`;
}
} }
return null; return null;
} }
+18 -2
View File
@@ -2,7 +2,7 @@ export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | stri
export function storeAuditLabel(auditStatus?: string | null): string { export function storeAuditLabel(auditStatus?: string | null): string {
const s = String(auditStatus || 'APPROVED').toUpperCase(); const s = String(auditStatus || 'APPROVED').toUpperCase();
if (s === 'PENDING') return '待总部审核'; if (s === 'PENDING') return '待审核';
if (s === 'REJECTED') return '审核驳回'; if (s === 'REJECTED') return '审核驳回';
if (s === 'APPROVED') return '审核通过'; if (s === 'APPROVED') return '审核通过';
return auditStatus || '—'; return auditStatus || '—';
@@ -21,10 +21,26 @@ export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase(); const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中'; if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '临时闭店'; if (s === 'PAUSED') return '临时闭店';
if (s === 'CLOSED') return '永久闭'; if (s === 'CLOSED') return '永久闭';
return status; return status;
} }
/** 列表右上角统一状态:审核未通过优先于营业状态 */
export function storeListBadge(store: {
status?: unknown;
auditStatus?: unknown;
}): { label: string; pillClass: string } {
const audit = String(store.auditStatus || 'APPROVED').toUpperCase();
if (audit === 'PENDING') {
return { label: '待审核', pillClass: storeAuditPillClass('PENDING') };
}
if (audit === 'REJECTED') {
return { label: '审核驳回', pillClass: storeAuditPillClass('REJECTED') };
}
const status = String(store.status || '').toUpperCase();
return { label: storeStatusLabel(status), pillClass: storeStatusPillClass(status) };
}
export function storeStatusPillClass(status: string): string { export function storeStatusPillClass(status: string): string {
const s = String(status).toUpperCase(); const s = String(status).toUpperCase();
if (s === 'OPEN') return 'partner-status-pill--open'; if (s === 'OPEN') return 'partner-status-pill--open';
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, request } from './api'; import { apiBase, request } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
const UPLOAD_TIMEOUT_MS = 120_000; const UPLOAD_TIMEOUT_MS = 120_000;
@@ -11,13 +16,23 @@ export type UploadFileResult = {
mock: boolean; mock: boolean;
}; };
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
async function uploadFileToOssInner( async function uploadFileToOssInner(
file: File, file: File,
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+13
View File
@@ -48,6 +48,19 @@ export async function canPartnerUseWechatLogin(options: {
} }
} }
/** 短信登录/发码前:校验手机号对应合伙人存在且未暂停 */
export async function checkPartnerPhoneForLogin(phone: string): Promise<PartnerPhoneCheckResponse> {
const normalized = phone.trim();
if (!/^1\d{10}$/.test(normalized)) {
throw new Error('请输入正确的手机号');
}
return request<PartnerPhoneCheckResponse>('PARTNER_H5', '/partner/auth/phone/check', {
method: 'POST',
body: JSON.stringify({ phone: normalized }),
silent: true,
});
}
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> { export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config'); return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
} }
+34 -4
View File
@@ -12,8 +12,10 @@ import {
} from '../lib/api'; } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
import type { PartnerPhoneCheckResponse } from '@dukang/shared-types';
import { import {
bindPartnerWechatAfterSmsLogin, bindPartnerWechatAfterSmsLogin,
checkPartnerPhoneForLogin,
fetchClientConfig, fetchClientConfig,
loginPartnerWithWechat, loginPartnerWithWechat,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
@@ -69,14 +71,17 @@ function formatPartnerError(e: unknown): string {
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) { if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
return '未找到合伙人账号'; return '未找到合伙人账号';
} }
if (text.includes('合伙人账号已停用')) { if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
return '合伙人账号已停用'; return '合伙人账号已暂停,无法登录';
} }
return text; return text;
} }
function formatWechatError(e: unknown): string { function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败'; const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
return '合伙人账号已暂停,无法登录';
}
if (text.includes('首次登录') || text.includes('手机验证码')) { if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
} }
@@ -138,11 +143,21 @@ export default function LoginPage() {
async function sendCode() { async function sendCode() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
if (!trimmedPhone) {
setMsg('请输入手机号');
return;
}
if (!/^1\d{10}$/.test(trimmedPhone)) {
setMsg('请输入正确的手机号');
return;
}
setMsg(''); setMsg('');
try { try {
await checkPartnerPhoneForLogin(trimmedPhone);
await request('PARTNER_H5', '/partner/auth/sms/send', { await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }), body: JSON.stringify({ phone: trimmedPhone, scene: 'PARTNER_LOGIN' }),
silent: true, silent: true,
}); });
setMsg('验证码已发送'); setMsg('验证码已发送');
@@ -168,12 +183,27 @@ export default function LoginPage() {
async function login() { async function login() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
const trimmedCode = code.trim();
if (!trimmedPhone) {
setMsg('请输入手机号');
return;
}
if (!/^1\d{10}$/.test(trimmedPhone)) {
setMsg('请输入正确的手机号');
return;
}
if (!trimmedCode) {
setMsg('请输入验证码');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
await checkPartnerPhoneForLogin(trimmedPhone);
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', { const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
silent: true, silent: true,
}); });
saveRememberedSession(data); saveRememberedSession(data);
+168 -73
View File
@@ -5,8 +5,10 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import ChinaRegionPicker from '../components/ChinaRegionPicker'; import ChinaRegionPicker from '../components/ChinaRegionPicker';
import OssUploadField from '../components/OssUploadField'; import OssUploadField from '../components/OssUploadField';
import MultiOssUploadField from '../components/MultiOssUploadField';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fetchClientConfig } from '../lib/wechat-auth';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import { resolveRegionBinding } from '../lib/china-region'; import { resolveRegionBinding } from '../lib/china-region';
@@ -40,15 +42,18 @@ import {
validateStoreStep3, validateStoreStep3,
patchEnvPhotoAt,
addEnvPhotoSlot,
MIN_ENV_PHOTO_COUNT, MIN_ENV_PHOTO_COUNT,
MAX_CONTRACT_COUNT,
} from '../lib/storeDraft'; } from '../lib/storeDraft';
import StorePackagesForm from '../components/StorePackagesForm'; import StorePackagesForm from '../components/StorePackagesForm';
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages'; import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const; const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
const DEFAULT_PARTNER_ONBOARD_CS_HINT =
'使用问题、提现问题等随时可联系【杜康好客】客服';
type StoreCategoryNode = { type StoreCategoryNode = {
id: string; id: string;
name: string; name: string;
@@ -57,6 +62,7 @@ type StoreCategoryNode = {
type FieldErrors = { type FieldErrors = {
phone?: string; phone?: string;
contactPhone?: string;
}; };
@@ -98,6 +104,10 @@ export default function StoreCreatePage() {
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [csAdded, setCsAdded] = useState(false);
const [csQrUrl, setCsQrUrl] = useState<string | null>(null);
const [csHint, setCsHint] = useState(DEFAULT_PARTNER_ONBOARD_CS_HINT);
const [csConfigLoading, setCsConfigLoading] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]); const [cities, setCities] = useState<OpenCityOption[]>([]);
@@ -149,6 +159,29 @@ export default function StoreCreatePage() {
}); });
}, [step]); }, [step]);
useEffect(() => {
if (step !== 4) return;
let cancelled = false;
setCsConfigLoading(true);
void fetchClientConfig()
.then((cfg) => {
if (cancelled) return;
setCsQrUrl((cfg.partnerOnboardCsQrUrl ?? '').trim() || null);
setCsHint((cfg.partnerOnboardCsHint ?? '').trim() || DEFAULT_PARTNER_ONBOARD_CS_HINT);
})
.catch(() => {
if (cancelled) return;
setCsQrUrl(null);
setCsHint(DEFAULT_PARTNER_ONBOARD_CS_HINT);
})
.finally(() => {
if (!cancelled) setCsConfigLoading(false);
});
return () => {
cancelled = true;
};
}, [step]);
useEffect(() => { useEffect(() => {
void fetchPartnerCities() void fetchPartnerCities()
@@ -238,9 +271,17 @@ export default function StoreCreatePage() {
setSubmitError(''); setSubmitError('');
if ('phone' in patch) { if ('phone' in patch || 'contactPhone' in patch) {
setFieldErrors((prev) => ({ ...prev, phone: undefined })); setFieldErrors((prev) => ({
...prev,
...('phone' in patch ? { phone: undefined } : {}),
...('contactPhone' in patch ? { contactPhone: undefined } : {}),
}));
} }
@@ -250,22 +291,6 @@ export default function StoreCreatePage() {
function patchEnvPhotoUrl(index: number, url: string) {
setForm((prev) => ({
...prev,
envPhotoUrls: patchEnvPhotoAt(prev.envPhotoUrls, index, url),
}));
setSubmitError('');
}
function bindRegionSelection(codes: string[]) { function bindRegionSelection(codes: string[]) {
const binding = resolveRegionBinding(codes, cities); const binding = resolveRegionBinding(codes, cities);
@@ -332,7 +357,9 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form); const msg = validateStoreStep3(form);
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) { if (isPhoneValidationMessage(msg)) {
setFieldErrors({ phone: msg }); setFieldErrors(
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
);
reportStepError(msg); reportStepError(msg);
return; return;
} }
@@ -348,12 +375,23 @@ export default function StoreCreatePage() {
async function submit(skipPackages = false) { async function submit(skipPackages = false) {
const qr = (csQrUrl ?? '').trim();
if (!qr) {
reportFormError('客服二维码暂未配置,请联系总部');
return;
}
if (!csAdded) {
reportFormError('请先勾选「我已添加【杜康好客】客服」');
return;
}
const msg = validateStoreStep3(form); const msg = validateStoreStep3(form);
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) { if (isPhoneValidationMessage(msg)) {
setFieldErrors({ phone: msg }); setFieldErrors(
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
);
return; return;
} }
reportFormError(msg); reportFormError(msg);
@@ -440,7 +478,11 @@ export default function StoreCreatePage() {
const envPhotoUrls = Array.from( const envPhotoUrls = Array.from(
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)), new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
).slice(0, 3); ).slice(0, 20);
const contractUrls = Array.from(
new Set((form.contractUrls ?? []).map((u) => u.trim()).filter(Boolean)),
).slice(0, MAX_CONTRACT_COUNT);
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', { const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
@@ -458,6 +500,8 @@ export default function StoreCreatePage() {
phone: form.phone.trim(), phone: form.phone.trim(),
contactPhone: form.contactPhone.trim() || form.phone.trim(),
district: form.district.trim(), district: form.district.trim(),
address: form.address.trim(), address: form.address.trim(),
@@ -488,7 +532,7 @@ export default function StoreCreatePage() {
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: form.contractUrl.trim() || undefined, contractUrls: contractUrls.length ? contractUrls : undefined,
bankAccountName: form.bankAccountName.trim(), bankAccountName: form.bankAccountName.trim(),
@@ -539,6 +583,7 @@ export default function StoreCreatePage() {
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100; const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
const canSubmitOnboard = !!csQrUrl?.trim() && csAdded && !submitting && !csConfigLoading;
const nextDisabled = submitting; const nextDisabled = submitting;
@@ -957,46 +1002,15 @@ export default function StoreCreatePage() {
<section className="partner-form-card"> <section className="partner-form-card">
<h3 className="headline-md"> <span className="text-primary">*</span></h3> <h3 className="headline-md"> <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}> {MIN_ENV_PHOTO_COUNT} 20 </p>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}> {MIN_ENV_PHOTO_COUNT} </p> <MultiOssUploadField
bizType="STORE_ENV"
<div className="partner-upload-grid"> maxCount={20}
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
{form.envPhotoUrls.map((url, index) => ( onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20`}
<OssUploadField />
key={index}
compact
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={() =>
setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
}
>
</button>
</section> </section>
@@ -1005,9 +1019,11 @@ export default function StoreCreatePage() {
<h3 className="headline-md"> <span className="text-primary">*</span></h3> <h3 className="headline-md"> <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}></p> <p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>
{MAX_CONTRACT_COUNT}
</p>
<OssUploadField <MultiOssUploadField
bizType="STORE_CONTRACT" bizType="STORE_CONTRACT"
@@ -1015,11 +1031,15 @@ export default function StoreCreatePage() {
accept="image/*,.pdf" accept="image/*,.pdf"
value={form.contractUrl} unit="个"
onChange={(contractUrl) => patchForm({ contractUrl })} maxCount={MAX_CONTRACT_COUNT}
label="上传合同副本" value={form.contractUrls ?? []}
onChange={(urls) => patchForm({ contractUrls: urls })}
label={`批量上传合同(${(form.contractUrls ?? []).length}/${MAX_CONTRACT_COUNT}`}
/> />
@@ -1137,7 +1157,43 @@ export default function StoreCreatePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}> <p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">phone_in_talk</span>
<input
type="tel"
placeholder="手机号或座机,如 0379-8888888"
value={form.contactPhone}
onChange={(e) => patchForm({ contactPhone: e.target.value })}
/>
</div>
{fieldErrors.contactPhone && (
<p className="partner-field-error" role="alert">{fieldErrors.contactPhone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
使 0379-8888888
</p> </p>
@@ -1150,6 +1206,37 @@ export default function StoreCreatePage() {
{step === 4 && ( {step === 4 && (
<> <>
<section className="partner-form-card partner-onboard-cs-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md"></h2>
</div>
<p className="label-md text-muted" style={{ marginBottom: 16, lineHeight: 1.5 }}>
{csHint}
</p>
{csConfigLoading ? (
<p className="body-md text-muted"></p>
) : csQrUrl ? (
<img
className="partner-onboard-cs-qr"
src={csQrUrl}
alt="杜康好客企微客服二维码"
/>
) : (
<p className="partner-form-error" role="alert">
</p>
)}
<label className="partner-onboard-cs-check">
<input
type="checkbox"
checked={csAdded}
disabled={!csQrUrl || submitting}
onChange={(e) => setCsAdded(e.target.checked)}
/>
<span></span>
</label>
</section>
<section className="partner-form-card"> <section className="partner-form-card">
<div className="partner-section-title"> <div className="partner-section-title">
<div className="partner-section-bar" /> <div className="partner-section-bar" />
@@ -1195,13 +1282,21 @@ export default function StoreCreatePage() {
) : ( ) : (
<> <>
<button type="button" className="partner-btn-outline" onClick={() => void submit(true)} disabled={submitting}> <button
type="button"
className="partner-btn-outline"
onClick={() => void submit(true)}
disabled={!canSubmitOnboard}
>
</button> </button>
<button type="button" className="partner-btn-primary" onClick={() => void submit(false)} disabled={submitting}> <button
type="button"
className="partner-btn-primary"
onClick={() => void submit(false)}
disabled={!canSubmitOnboard}
>
{submitting ? '提交中…' : '提交'} {submitting ? '提交中…' : '提交'}
</button> </button>
</> </>
+36 -27
View File
@@ -5,8 +5,9 @@ import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess'; import { canManagePartnerStore } from '../lib/partnerAccess';
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft'; import { MIN_ENV_PHOTO_COUNT, normalizeStringArray } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField'; import OssUploadField from '../components/OssUploadField';
import MultiOssUploadField from '../components/MultiOssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay'; import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import { import {
canPartnerOpenStore, canPartnerOpenStore,
@@ -42,7 +43,8 @@ export default function StoreDetailPage() {
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
phone: '', loginPhone: '',
contactPhone: '',
address: '', address: '',
intro: '', intro: '',
benefitUsageRule: '', benefitUsageRule: '',
@@ -63,7 +65,8 @@ export default function StoreDetailPage() {
setStore(data); setStore(data);
setForm({ setForm({
name: String(data.name || ''), name: String(data.name || ''),
phone: String(data.phone || ''), loginPhone: String(data.phone || ''),
contactPhone: String(data.contactPhone || data.phone || ''),
address: String(data.address || ''), address: String(data.address || ''),
intro: String(data.intro || ''), intro: String(data.intro || ''),
benefitUsageRule: (() => { benefitUsageRule: (() => {
@@ -163,7 +166,7 @@ export default function StoreDetailPage() {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
name: form.name.trim(), name: form.name.trim(),
phone: form.phone.trim(), contactPhone: form.contactPhone.trim(),
address: form.address.trim(), address: form.address.trim(),
intro: form.intro.trim(), intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null, benefitUsageRule: form.benefitUsageRule.trim() || null,
@@ -282,7 +285,7 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3> <h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
<button type="button" className="partner-btn-outline" onClick={() => navigate(`/stores/${id}/packages`)}> <button type="button" className="partner-btn-outline partner-btn-outline--compact" onClick={() => navigate(`/stores/${id}/packages`)}>
</button> </button>
</div> </div>
@@ -345,12 +348,31 @@ export default function StoreDetailPage() {
<label></label> <label></label>
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} /> <input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
</div> </div>
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">lock</span>
<input disabled type="tel" value={form.loginPhone} readOnly />
</div>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<div className="partner-field-input"> <div className="partner-field-input">
<span className="material-symbols-outlined">call</span> <span className="material-symbols-outlined">phone_in_talk</span>
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /> <input
disabled={readOnly}
type="tel"
placeholder="手机号或座机,如 0379-8888888"
value={form.contactPhone}
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
/>
</div> </div>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
使
</p>
</div> </div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
@@ -409,26 +431,13 @@ export default function StoreDetailPage() {
</div> </div>
{canMutate && !readOnly ? ( {canMutate && !readOnly ? (
<> <>
<div className="partner-upload-grid"> <MultiOssUploadField
{envPhotoUrls.map((url, index) => ( bizType="STORE_ENV"
<OssUploadField maxCount={20}
key={index} value={uniqueEnvUrls(envPhotoUrls)}
compact onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
bizType="STORE_ENV" label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20`}
mediaType="IMAGE" />
value={url}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
>
</button>
<button <button
type="button" type="button"
className="partner-btn-outline" className="partner-btn-outline"
+144 -92
View File
@@ -8,9 +8,7 @@ import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAcce
import { import {
canPartnerOpenStore, canPartnerOpenStore,
storeAuditLabel, storeAuditLabel,
storeAuditPillClass, storeListBadge,
storeStatusLabel,
storeStatusPillClass,
type StoreStatusValue, type StoreStatusValue,
} from '../lib/storeStatus'; } from '../lib/storeStatus';
import { usePartnerPageView } from '../lib/usePageView'; import { usePartnerPageView } from '../lib/usePageView';
@@ -20,10 +18,10 @@ type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
const FILTERS: { key: StatusFilter; label: string }[] = [ const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' }, { key: 'ALL', label: '全部' },
{ key: 'OPEN', label: '营业中' }, { key: 'OPEN', label: '营业中' },
{ key: 'PAUSED', label: '时闭店' }, { key: 'PAUSED', label: '时闭店' },
{ key: 'PENDING_AUDIT', label: '待审核' }, { key: 'PENDING_AUDIT', label: '待审核' },
{ key: 'REJECTED', label: '已驳回' }, { key: 'REJECTED', label: '已驳回' },
{ key: 'CLOSED', label: '关闭' }, { key: 'CLOSED', label: '永久闭店' },
]; ];
export default function StoreListPage() { export default function StoreListPage() {
@@ -46,7 +44,10 @@ export default function StoreListPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) {
navigate('/login');
return;
}
void loadStores(); void loadStores();
}, [navigate, loadStores]); }, [navigate, loadStores]);
@@ -54,20 +55,28 @@ export default function StoreListPage() {
document.title = canMutate ? '门店管理' : '我的门店'; document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]); }, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => { const filtered = useMemo(
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q); () =>
const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); stores.filter((s) => {
const status = String(s.status).toUpperCase(); const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
let matchStatus = true; const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING'; const status = String(s.status).toUpperCase();
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED'; let matchStatus = true;
else if (filter !== 'ALL') matchStatus = status === filter; if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
return matchQ && matchStatus; else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
}), [stores, q, filter]); else if (filter !== 'ALL') matchStatus = status === filter;
return matchQ && matchStatus;
}),
[stores, q, filter],
);
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) { async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) { if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门'); setError(
auditStatus === 'REJECTED'
? '门店审核未通过,请查看驳回原因并修改后重新提交'
: '门店尚在总部审核中,通过后方可开门',
);
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
@@ -82,7 +91,8 @@ export default function StoreListPage() {
body: JSON.stringify({ status: next }), body: JSON.stringify({ status: next }),
}); });
await loadStores(); await loadStores();
if (next === 'OPEN') toastSuccess('开店成功'); if (next === 'OPEN') toastSuccess('已营业');
if (next === 'PAUSED') toastSuccess('已临时闭店');
} catch { } catch {
/* request 已 toast */ /* request 已 toast */
} finally { } finally {
@@ -102,6 +112,7 @@ export default function StoreListPage() {
body: JSON.stringify({ status: 'CLOSED' }), body: JSON.stringify({ status: 'CLOSED' }),
}); });
await loadStores(); await loadStores();
toastSuccess('已永久闭店');
} catch { } catch {
/* request 已 toast */ /* request 已 toast */
} finally { } finally {
@@ -111,29 +122,43 @@ export default function StoreListPage() {
return ( return (
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top"> <PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>} {error && (
<p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>
{error}
</p>
)}
<div className="partner-sticky-filter"> <div className="partner-sticky-filter">
<div className="partner-search"> <div className="partner-search">
<span className="material-symbols-outlined">search</span> <span className="material-symbols-outlined">search</span>
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} /> <input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="partner-chips"> <div className="partner-store-filter-row">
{FILTERS.map((f) => ( <label className="partner-store-filter-label" htmlFor="partner-store-status-filter">
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
{f.label} </label>
</button> <select
))} id="partner-store-status-filter"
className="partner-store-filter-select"
value={filter}
onChange={(e) => setFilter(e.target.value as StatusFilter)}
>
{FILTERS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
</div> </div>
</div> </div>
{canCreate && ( {canCreate && (
<Link to="/stores/new" className="partner-fab-link"> <Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary"> <button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span> <span className="material-symbols-outlined">add_business</span>
</button> </button>
</Link> </Link>
)} )}
{filtered.length === 0 && <div className="empty"></div>} {filtered.length === 0 && <div className="empty"></div>}
@@ -145,87 +170,114 @@ export default function StoreListPage() {
const dim = currentStatus === 'CLOSED'; const dim = currentStatus === 'CLOSED';
const storeName = String(s.name || '未命名门店'); const storeName = String(s.name || '未命名门店');
const busy = updatingId === storeId; const busy = updatingId === storeId;
const canOpen = canPartnerOpenStore(auditStatus); const badge = storeListBadge(s);
const switchOn = currentStatus === 'OPEN';
const switchDisabled =
busy ||
currentStatus === 'CLOSED' ||
auditStatus === 'PENDING' ||
auditStatus === 'REJECTED';
return ( return (
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}> <div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}> <Link
to={`/stores/${storeId}`}
className="partner-store-card-hit"
style={{ color: 'inherit', textDecoration: 'none' }}
>
<div className="partner-store-card-header"> <div className="partner-store-card-header">
<div> <div className="partner-store-card-main">
<p className="label-md text-muted" style={{ marginBottom: 2 }}></p> <p className="label-md text-muted" style={{ marginBottom: 2 }}>
</p>
<h3 className="headline-md">{storeName}</h3> <h3 className="headline-md">{storeName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p> <p className="label-md text-muted" style={{ marginTop: 4 }}>
{auditStatus !== 'APPROVED' && ( {String(s.address || s.district || '')}
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}> </p>
{storeAuditLabel(auditStatus)} {auditStatus === 'REJECTED' && s.rejectReason ? (
{auditStatus === 'REJECTED' && s.rejectReason ? `${String(s.rejectReason)}` : ''} <p
className="label-md"
style={{ marginTop: 8, color: 'var(--color-heritage-red)' }}
>
{storeAuditLabel(auditStatus)}{String(s.rejectReason)}
</p> </p>
)} ) : null}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{auditStatus !== 'APPROVED' ? (
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
{storeAuditLabel(auditStatus)}
</span>
) : (
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
{storeStatusLabel(currentStatus)}
</span>
)}
</div> </div>
<span className={`partner-status-pill partner-store-card-badge ${badge.pillClass}`}>
{badge.label}
</span>
</div> </div>
</Link> </Link>
{canMutate && ( {canMutate ? (
<div className="partner-store-card-actions"> <div className="partner-store-card-actions partner-store-card-actions--v3416">
<button {currentStatus !== 'CLOSED' ? (
type="button" <>
className="btn btn-outline" <label
style={{ fontSize: 12, padding: '8px 12px' }} className={`partner-store-switch${switchDisabled ? ' partner-store-switch--disabled' : ''}`}
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'} >
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)} <input
> type="checkbox"
role="switch"
</button> checked={switchOn}
<button disabled={switchDisabled}
type="button" aria-label={switchOn ? '营业中,点击临时闭店' : '临时闭店,点击营业'}
className="btn btn-outline" onChange={() => {
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }} const next: StoreStatusValue = switchOn ? 'PAUSED' : 'OPEN';
disabled={busy || currentStatus === 'CLOSED'} void updateStatus(storeId, next, auditStatus);
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)} }}
> />
<span className="partner-store-switch-track" aria-hidden />
</button> <span className="partner-store-switch-text">
{currentStatus === 'PAUSED' && ( {switchOn ? '开启(营业中)' : '关闭(临时闭店)'}
<button </span>
type="button" </label>
className="btn btn-outline" <button
style={{ fontSize: 12, padding: '8px 12px' }} type="button"
disabled={busy || !canOpen} className="partner-store-close-btn"
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)} disabled={busy}
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
>
</button>
</>
) : null}
<Link
to={`/stores/${storeId}`}
className="partner-menu-icon"
style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}
> >
{canOpen ? '开门营业' : '待审核通过'} <span className="material-symbols-outlined" style={{ fontSize: 20 }}>
</button> edit
)} </span>
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}> </Link>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span> </div>
</Link> ) : null}
</div>
)}
</div> </div>
); );
})} })}
{closeTarget && ( {closeTarget && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}> <div
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}> className="partner-ship-modal-backdrop"
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3> role="presentation"
onClick={() => setCloseTarget(null)}
>
<div
className="partner-ship-modal"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<h3 className="headline-md" style={{ marginBottom: 8 }}>
</h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}> <p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p> </p>
<div className="partner-ship-actions"> <div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}> <button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
</button> </button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}> <button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button> </button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types'; import type { StorePackagesResponse } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import StorePackagesForm from '../components/StorePackagesForm'; import StorePackagesForm from '../components/StorePackagesForm';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
@@ -30,7 +31,18 @@ export default function StorePackagesPage() {
: data.live?.length : data.live?.length
? data.live ? data.live
: [emptyPackage()]; : [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))); setItems(
base.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
}),
);
setPending(data.pendingRequest ?? null); setPending(data.pendingRequest ?? null);
}) })
.catch((e) => setError(e instanceof Error ? e.message : '加载失败')) .catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
+185
View File
@@ -304,6 +304,30 @@ html {
cursor: pointer; cursor: pointer;
} }
/* 卡片标题旁的紧凑型描边按钮:不拉伸、缩小尺寸、贴右对齐 */
.partner-btn-outline--compact {
flex: none;
height: 30px;
padding: 0 14px;
border: 1px solid var(--color-outline);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-primary);
font-family: var(--font-label);
font-size: 13px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.partner-btn-outline--compact:hover {
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border-color: var(--color-primary);
}
.partner-btn-outline--compact:active {
transform: scale(0.97);
}
.partner-btn-wechat { .partner-btn-wechat {
width: 100%; width: 100%;
padding: 16px; padding: 16px;
@@ -1960,6 +1984,134 @@ nav.app-tabbar .app-tabbar-label {
color: #fff; color: #fff;
} }
.partner-store-filter-row {
display: flex;
align-items: center;
gap: 10px;
}
.partner-store-filter-label {
flex-shrink: 0;
font-family: var(--font-label);
font-size: 13px;
font-weight: 500;
color: var(--color-on-surface-variant);
}
.partner-store-filter-select {
flex: 1;
height: 40px;
padding: 0 12px;
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
font-size: 14px;
color: var(--color-ink-black);
}
.partner-store-filter-select:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(166, 29, 36, 0.2);
}
.partner-store-card-main {
flex: 1;
min-width: 0;
padding-right: 8px;
}
.partner-store-card-badge {
flex-shrink: 0;
white-space: nowrap;
}
.partner-store-card-actions--v3416 {
align-items: center;
flex-wrap: wrap;
}
.partner-store-card-actions--v3416 > .partner-store-switch {
flex: 1 1 auto;
min-width: 140px;
}
.partner-store-card-actions--v3416 > .partner-store-close-btn {
flex: 0 0 auto;
}
.partner-store-card-actions--v3416 > .partner-menu-icon {
flex: 0 0 auto;
margin-left: auto;
}
.partner-store-switch {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-family: var(--font-label);
font-size: 12px;
color: var(--color-on-surface-variant);
user-select: none;
}
.partner-store-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.partner-store-switch-track {
position: relative;
width: 44px;
height: 24px;
border-radius: 999px;
background: var(--color-surface-container-highest);
transition: background 0.2s ease;
flex-shrink: 0;
}
.partner-store-switch-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease;
}
.partner-store-switch input:checked + .partner-store-switch-track {
background: var(--color-success-green);
}
.partner-store-switch input:checked + .partner-store-switch-track::after {
transform: translateX(20px);
}
.partner-store-switch--disabled {
opacity: 0.45;
cursor: not-allowed;
}
.partner-store-close-btn {
flex: 0 0 auto !important;
padding: 8px 12px !important;
border: 1px solid rgba(166, 29, 36, 0.35);
background: transparent;
color: var(--color-heritage-red);
font-weight: 500;
}
.partner-store-close-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.partner-store-card { .partner-store-card {
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -2061,6 +2213,39 @@ nav.app-tabbar .app-tabbar-label {
margin: 0 var(--space-page) var(--space-md); margin: 0 var(--space-page) var(--space-md);
} }
.partner-onboard-cs-card {
text-align: center;
}
.partner-onboard-cs-qr {
display: block;
width: min(220px, 70vw);
height: auto;
margin: 0 auto 16px;
border-radius: 8px;
background: #fff;
border: 1px solid var(--color-surface-container);
}
.partner-onboard-cs-check {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
font-family: var(--font-label);
font-size: 14px;
color: var(--color-ink-black);
cursor: pointer;
text-align: left;
}
.partner-onboard-cs-check input {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* ── Store detail ── */ /* ── Store detail ── */
.partner-detail-page { .partner-detail-page {
padding-bottom: 96px; padding-bottom: 96px;
+2 -1
View File
@@ -13,7 +13,8 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"paths": { "paths": {
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"] "@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"],
"@dukang/domain": ["../../packages/domain/src/index.ts"]
} }
}, },
"include": ["src"] "include": ["src"]
+3 -1
View File
@@ -9,12 +9,14 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'), '@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
}, },
}, },
server: { server: {
host: true,
port: 5175, port: 5175,
proxy: { proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3000', '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
}, },
}, },
}); });
+107 -10
View File
@@ -1,6 +1,10 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types'; import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
} from '@dukang/shared-types';
import { emptyPackage, type PackageFormItem } from '../lib/storePackages'; import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
import { uploadFileToOss } from '../lib/upload';
type Props = { type Props = {
items: PackageFormItem[]; items: PackageFormItem[];
@@ -10,6 +14,37 @@ type Props = {
export default function ShopPackagesForm({ items, onChange, disabled }: Props) { export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({}); const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
async function pickPackageImages(pkgIndex: number, fileList?: FileList | null) {
if (!fileList?.length || disabled) return;
const current = items[pkgIndex];
const existing =
Array.isArray(current.imageUrls) && current.imageUrls.length > 0
? current.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
: current.imageUrl
? [String(current.imageUrl)]
: [];
const room = Math.max(0, STORE_PACKAGE_IMAGE_MAX_COUNT - existing.length);
const files = Array.from(fileList).slice(0, room);
if (!files.length) return;
setUploadingIndex(pkgIndex);
try {
const appended: string[] = [];
for (const file of files) {
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
appended.push(result.url);
}
const next = [...existing, ...appended];
updateAt(pkgIndex, { imageUrls: next, imageUrl: next[0] ?? '' });
} finally {
setUploadingIndex(null);
const input = fileRefs.current[pkgIndex];
if (input) input.value = '';
}
}
function updateAt(index: number, patch: Partial<PackageFormItem>) { function updateAt(index: number, patch: Partial<PackageFormItem>) {
onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item))); onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item)));
@@ -44,6 +79,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
{list.map((item, index) => { {list.map((item, index) => {
const isCollapsed = !!collapsed[index]; const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`; const displayName = item.name.trim() || `套餐 ${index + 1}`;
const filled =
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
: item.imageUrl
? [String(item.imageUrl)]
: [];
return ( return (
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}> <section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
<div className="shop-packages-card-head"> <div className="shop-packages-card-head">
@@ -69,7 +110,7 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
{!isCollapsed ? ( {!isCollapsed ? (
<> <>
<label className="shop-packages-field"> <label className="shop-packages-field">
<span className="shop-packages-label"> *</span> <span className="shop-packages-label"></span>
<input <input
className="shop-packages-input" className="shop-packages-input"
placeholder="如:套餐A" placeholder="如:套餐A"
@@ -80,12 +121,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
</label> </label>
<label className="shop-packages-field"> <label className="shop-packages-field">
<span className="shop-packages-label"> *</span> <span className="shop-packages-label"></span>
<input <input
className="shop-packages-input" className="shop-packages-input"
type="number" type="number"
min={0} min={0}
step={0.01} step="0.01"
placeholder="198" placeholder="198"
value={item.price} value={item.price}
disabled={disabled} disabled={disabled}
@@ -94,10 +135,10 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
</label> </label>
<label className="shop-packages-field"> <label className="shop-packages-field">
<span className="shop-packages-label"> *</span> <span className="shop-packages-label"></span>
<textarea <textarea
className="shop-packages-textarea" className="shop-packages-input"
rows={3} rows={2}
placeholder="红烧肉、红烧鱼、油焖茄子" placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes} value={item.dishes}
disabled={disabled} disabled={disabled}
@@ -107,8 +148,9 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
<label className="shop-packages-field"> <label className="shop-packages-field">
<span className="shop-packages-label">使</span> <span className="shop-packages-label">使</span>
<input <textarea
className="shop-packages-input" className="shop-packages-input"
rows={2}
placeholder="节假日除外" placeholder="节假日除外"
value={item.usableTime || ''} value={item.usableTime || ''}
disabled={disabled} disabled={disabled}
@@ -116,10 +158,65 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
/> />
</label> </label>
<div className="shop-packages-field">
<span className="shop-packages-label">
{STORE_PACKAGE_IMAGE_MAX_COUNT}
</span>
{filled.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{filled.map((url, imgIndex) => (
<div key={`${url}-${imgIndex}`} style={{ position: 'relative', width: 88 }}>
<img
src={url}
alt=""
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
/>
{!disabled ? (
<button
type="button"
className="shop-packages-remove"
style={{ position: 'absolute', top: 2, right: 2, margin: 0 }}
onClick={() => {
const next = filled.filter((_, i) => i !== imgIndex);
updateAt(index, { imageUrls: next, imageUrl: next[0] ?? '' });
}}
>
</button>
) : null}
</div>
))}
</div>
) : null}
<input
ref={(el) => {
fileRefs.current[index] = el;
}}
type="file"
accept="image/*"
multiple
hidden
disabled={disabled || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
onChange={(e) => void pickPackageImages(index, e.target.files)}
/>
<button
type="button"
className="shop-packages-add"
style={{ marginTop: 0 }}
disabled={disabled || uploadingIndex === index || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
onClick={() => fileRefs.current[index]?.click()}
>
{uploadingIndex === index
? '上传中…'
: `批量上传(${filled.length}/${STORE_PACKAGE_IMAGE_MAX_COUNT}`}
</button>
</div>
<label className="shop-packages-field"> <label className="shop-packages-field">
<span className="shop-packages-label"></span> <span className="shop-packages-label"></span>
<input <textarea
className="shop-packages-input" className="shop-packages-input"
rows={2}
placeholder="不可叠加" placeholder="不可叠加"
value={item.otherNotes || ''} value={item.otherNotes || ''}
disabled={disabled} disabled={disabled}
@@ -2,7 +2,10 @@ type WechatScanAuthModalProps = {
open: boolean; open: boolean;
loading?: boolean; loading?: boolean;
error?: string; error?: string;
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
mode?: 'bind' | 'recover';
onAuthorize: () => void; onAuthorize: () => void;
onRefresh?: () => void;
onCancel: () => void; onCancel: () => void;
}; };
@@ -10,28 +13,43 @@ export default function WechatScanAuthModal({
open, open,
loading, loading,
error, error,
mode = 'bind',
onAuthorize, onAuthorize,
onRefresh,
onCancel, onCancel,
}: WechatScanAuthModalProps) { }: WechatScanAuthModalProps) {
if (!open) return null; if (!open) return null;
const isRecover = mode === 'recover';
return ( return (
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title"> <div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
<div className="shop-scan-auth-card"> <div className="shop-scan-auth-card">
<div className="shop-scan-auth-icon"> <div className="shop-scan-auth-icon">
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span> <span className="material-symbols-outlined shop-fill-icon">
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
</span>
</div> </div>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title"></h2> <h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
{isRecover ? '扫码能力未就绪' : '微信授权'}
</h2>
<p className="shop-scan-auth-desc"> <p className="shop-scan-auth-desc">
使使 {isRecover
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
</p> </p>
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>} {error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
<div className="shop-scan-auth-actions"> <div className="shop-scan-auth-actions">
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}> <button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
</button> </button>
{isRecover && onRefresh ? (
<button type="button" className="shop-scan-auth-confirm" onClick={onRefresh} disabled={loading}>
</button>
) : null}
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}> <button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
{loading ? '跳转授权中…' : '微信授权'} {loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
</button> </button>
</div> </div>
</div> </div>
@@ -19,6 +19,7 @@ import {
import { import {
fetchClientConfig, fetchClientConfig,
handleShopWechatCallback, handleShopWechatCallback,
handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
isShopWechatUnboundError, isShopWechatUnboundError,
SHOP_WX_NEED_PHONE_LOGIN_MSG, SHOP_WX_NEED_PHONE_LOGIN_MSG,
@@ -79,7 +80,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
try { try {
const config = await fetchClientConfig(); const config = await fetchClientConfig();
if (isWxAuthorizeEnabled(config)) { if (isWxAuthorizeEnabled(config)) {
const result = await handleShopWechatCallback(); const result = await handleShopWechatCallbackOnce();
if (result && !cancelled) { if (result && !cancelled) {
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
if (session) applySession(session); if (session) applySession(session);
+8
View File
@@ -1,3 +1,5 @@
import { reportApiError } from '@dukang/client-logging';
export const apiBase = '/api/v1'; export const apiBase = '/api/v1';
const CLIENT_APP = 'SHOP_H5'; const CLIENT_APP = 'SHOP_H5';
@@ -213,6 +215,12 @@ async function rawRequest<T>(
if (json.code !== 0) { if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number }; const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = res.status >= 500 ? res.status : json.code; err.status = res.status >= 500 ? res.status : json.code;
if (json.code === 400) {
reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
);
}
throw err; throw err;
} }
return json.data as T; return json.data as T;
+55
View File
@@ -0,0 +1,55 @@
import { isIosDevice } from '@dukang/weixin-sdk';
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
export function markPendingScanAfterAuth(): void {
try {
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
} catch {
/* ignore */
}
}
export function peekPendingScanAfterAuth(): boolean {
try {
return sessionStorage.getItem(SHOP_PENDING_SCAN_KEY) === '1';
} catch {
return false;
}
}
export function clearPendingScanAfterAuth(): void {
try {
sessionStorage.removeItem(SHOP_PENDING_SCAN_KEY);
} catch {
/* ignore */
}
}
/** OAuth(含短信登录后绑微信)回跳后,标记下一次扫码需要加长预热 */
export function markScanWarmupAfterAuth(): void {
try {
sessionStorage.setItem(SHOP_SCAN_WARMUP_KEY, '1');
} catch {
/* ignore */
}
}
export function consumeScanWarmupAfterAuth(): boolean {
try {
if (sessionStorage.getItem(SHOP_SCAN_WARMUP_KEY) !== '1') return false;
sessionStorage.removeItem(SHOP_SCAN_WARMUP_KEY);
return true;
} catch {
return false;
}
}
/** OAuth 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */
export function getPostAuthScanDelayMs(): number {
return isIosDevice() ? 1200 : 600;
}
+38 -10
View File
@@ -1,5 +1,9 @@
import type { StorePackageItemDto } from '@dukang/shared-types'; import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types'; import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
export type PackageFormItem = StorePackageItemDto; export type PackageFormItem = StorePackageItemDto;
@@ -10,21 +14,36 @@ export function emptyPackage(index = 0): PackageFormItem {
dishes: '', dishes: '',
usableTime: '', usableTime: '',
otherNotes: '', otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index, sortOrder: index,
}; };
} }
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] { export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw return raw
.map((item, index) => ({ .map((item, index) => {
name: item.name.trim(), const imageUrls = normalizeStorePackageImageUrls(item);
price: item.price.trim(), return {
dishes: item.dishes.trim(), name: item.name.trim(),
usableTime: item.usableTime?.trim() || '', price: item.price.trim(),
otherNotes: item.otherNotes?.trim() || '', dishes: item.dishes.trim(),
sortOrder: index, usableTime: item.usableTime?.trim() || '',
})) otherNotes: item.otherNotes?.trim() || '',
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes); imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: index,
};
})
.filter(
(item) =>
item.name ||
item.price ||
item.dishes ||
item.usableTime ||
item.otherNotes ||
item.imageUrls.length > 0,
);
} }
export function validatePackageFormItems(items: PackageFormItem[]): string | null { export function validatePackageFormItems(items: PackageFormItem[]): string | null {
@@ -38,6 +57,15 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
const price = Number(item.price); const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`; if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`; if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
return `${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`;
}
} }
return null; 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);
}
+11 -2
View File
@@ -1,5 +1,10 @@
import { apiBase } from './api'; import { apiBase } from './api';
import { getStoreProfile } from './api'; import { getStoreProfile } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
const UPLOAD_TIMEOUT_MS = 120_000; const UPLOAD_TIMEOUT_MS = 120_000;
@@ -15,9 +20,13 @@ export type RegisteredResource = {
url: string; url: string;
}; };
async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> { export async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', compressed);
formData.append('bizType', bizType); formData.append('bizType', bizType);
formData.append('mediaType', 'IMAGE'); formData.append('mediaType', 'IMAGE');
+23 -3
View File
@@ -2,6 +2,7 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin'; import { isWechatEnv, weixinSdk } from './weixin';
import { markScanWarmupAfterAuth } from './shop-scan-auth';
import { request, saveWechatSession, type ShopSessionPayload } from './api'; import { request, saveWechatSession, type ShopSessionPayload } from './api';
export type ShopAccountProfile = { export type ShopAccountProfile = {
@@ -138,6 +139,20 @@ export async function handleShopWechatCallback(): Promise<WechatLoginResult | nu
return weixinSdk.handleOAuthCallback(); return weixinSdk.handleOAuthCallback();
} }
let oauthCallbackInflight: Promise<WechatLoginResult | null> | null = null;
/** 全局单例:避免 Context 与页面 effect 重复消费 OAuth code */
export async function handleShopWechatCallbackOnce(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
const params = new URLSearchParams(window.location.search);
if (!params.get('code')) return null;
if (oauthCallbackInflight) return oauthCallbackInflight;
oauthCallbackInflight = handleShopWechatCallback().finally(() => {
oauthCallbackInflight = null;
});
return oauthCallbackInflight;
}
/** /**
* *
* session = void = * session = void =
@@ -148,15 +163,19 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
if (!isWechatEnv()) { if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG); throw new Error(WECHAT_INAPP_REQUIRED_MSG);
} }
markScanWarmupAfterAuth();
const result = await weixinSdk.login(); const result = await weixinSdk.login();
if (result) return handleShopWechatLoginResult(result); if (result) return handleShopWechatLoginResult(result);
} }
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */ /** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindShopWechatAfterSmsLogin(): Promise<void> { export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
const config = await fetchClientConfig(); const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return; if (!isWxAuthorizeEnabled(config)) return 'skipped';
if (!isWechatEnv()) return; if (!isWechatEnv()) return 'skipped';
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
if (session?.account?.hasWechat) return 'skipped';
markScanWarmupAfterAuth();
await weixinSdk.login(); await weixinSdk.login();
} }
@@ -166,5 +185,6 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) { if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG); throw new Error(WECHAT_INAPP_REQUIRED_MSG);
} }
markScanWarmupAfterAuth();
return weixinSdk.login(); return weixinSdk.login();
} }
+412 -73
View File
@@ -1,285 +1,624 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan'; import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import { import {
authorizeShopWechat, authorizeShopWechat,
checkNeedsWechatAuth, checkNeedsWechatAuth,
fetchShopAccount, fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin'; import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import {
clearPendingScanAfterAuth,
consumeScanWarmupAfterAuth,
getPostAuthScanDelayMs,
markPendingScanAfterAuth,
peekPendingScanAfterAuth,
} from '../lib/shop-scan-auth';
import WechatScanAuthModal from '../components/WechatScanAuthModal'; import WechatScanAuthModal from '../components/WechatScanAuthModal';
import { useStorePageView } from '../lib/usePageView'; import { useStorePageView } from '../lib/usePageView';
import { trackStore } from '../lib/analytics'; import { trackStore } from '../lib/analytics';
const PENDING_SCAN_KEY = 'shop_pending_scan';
function formatMoney(n: number) { function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
} }
function formatScanError(e: unknown): string {
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试'; const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; if (/invalid signature|config:fail|signature/i.test(msg)) {
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
} }
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)'; if (isScanPermissionWarmupError(msg)) {
if (opts?.afterAuth) {
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
}
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
} }
return msg; return msg;
} }
function isScanRecoverableError(msg: string): boolean {
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
}
export default function HomePage() { export default function HomePage() {
useStorePageView('store_home_view'); useStorePageView('store_home_view');
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams(); const { ready, authenticated } = useStoreSession();
const [searchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [scanMsg, setScanMsg] = useState(''); const [scanMsg, setScanMsg] = useState('');
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false);
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
const [authLoading, setAuthLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState(''); const [authError, setAuthError] = useState('');
const pendingScanStartedRef = useRef(false);
const loadDashboard = useCallback(() => { const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard') return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => { .then((d) => {
setDash(d); setDash(d);
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
useEffect(() => { useEffect(() => {
void loadDashboard(); void loadDashboard();
}, [loadDashboard]); }, [loadDashboard]);
useEffect(() => { useEffect(() => {
function onResume() { function onResume() {
setScanning(false); setScanning(false);
void loadDashboard(); void loadDashboard();
} }
function onVisibility() { function onVisibility() {
if (document.visibilityState === 'visible') onResume(); if (document.visibilityState === 'visible') onResume();
} }
document.addEventListener('visibilitychange', onVisibility); document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pageshow', onResume); window.addEventListener('pageshow', onResume);
window.addEventListener('focus', onResume); window.addEventListener('focus', onResume);
return () => { return () => {
document.removeEventListener('visibilitychange', onVisibility); document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pageshow', onResume); window.removeEventListener('pageshow', onResume);
window.removeEventListener('focus', onResume); window.removeEventListener('focus', onResume);
}; };
}, [loadDashboard]); }, [loadDashboard]);
async function runScan(opts?: { postAuthWarmup?: boolean }) {
trackStore('store_redeem_scan_start');
if (!isWechatEnv()) { const runScan = useCallback(
setScanMsg('请在微信内打开门店端进行扫码核销');
return; async (opts?: { postAuthWarmup?: boolean }) => {
}
setScanning(true); trackStore('store_redeem_scan_start');
setScanMsg('');
try { if (!isWechatEnv()) {
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
if (opts?.postAuthWarmup) { setScanMsg('请在微信内打开门店端进行扫码核销');
weixinSdk.reset();
}
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode(
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
);
if (!raw) {
void loadDashboard();
return; return;
} }
const token = parseRedeemTokenFromScan(raw);
if (!token) { setScanning(true);
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return; if (!opts?.postAuthWarmup) {
setScanMsg('');
} }
navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) { try {
setScanMsg(formatScanError(e));
} finally { // iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL
setScanning(false); if (opts?.postAuthWarmup || isIosDevice()) {
}
} weixinSdk.reset();
}
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode(
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
);
if (!raw) {
void loadDashboard();
return;
}
const token = parseRedeemTokenFromScan(raw);
if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return;
}
navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) {
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
setScanMsg(tip);
if (isScanRecoverableError(tip)) {
setAuthModalMode('recover');
setAuthError(tip);
setAuthModalOpen(true);
}
} finally {
setScanning(false);
}
},
[loadDashboard, navigate],
);
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback() if (!ready || !authenticated || !isWechatEnv()) return;
.then((result) => {
if (!result) return; if (searchParams.get('code')) return;
const session = handleShopWechatLoginResult(result);
if (session) { if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
applySession(session);
}
setAuthModalOpen(false);
setAuthError(''); pendingScanStartedRef.current = true;
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
stripOAuthParamsFromLocation(); clearPendingScanAfterAuth();
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1'; setAuthModalOpen(false);
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) { setAuthLoading(false);
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400); setAuthError('');
}
}) setScanMsg('微信授权成功,正在准备扫码…');
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败');
});
}, [searchParams, applySession, setSearchParams]); const timer = window.setTimeout(() => {
void runScan({ postAuthWarmup: true });
}, getPostAuthScanDelayMs());
return () => window.clearTimeout(timer);
}, [ready, authenticated, searchParams, runScan]);
async function handleScan() { async function handleScan() {
setScanMsg(''); setScanMsg('');
if (!isWechatEnv()) { if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销'); setScanMsg('请在微信内打开门店端进行扫码核销');
return; return;
} }
try { try {
const profile = await fetchShopAccount(); const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) { if (await checkNeedsWechatAuth(profile)) {
pendingScanStartedRef.current = false;
setAuthModalMode('bind');
setAuthError('');
setAuthModalOpen(true); setAuthModalOpen(true);
return; return;
} }
await runScan();
const needWarmup = consumeScanWarmupAfterAuth();
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
} catch (e) { } catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
} }
} }
async function startWechatAuth() { async function startWechatAuth() {
setAuthLoading(true); setAuthLoading(true);
setAuthError(''); setAuthError('');
try { try {
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
pendingScanStartedRef.current = false;
markPendingScanAfterAuth();
await authorizeShopWechat(); await authorizeShopWechat();
} catch (e) { } catch (e) {
sessionStorage.removeItem(PENDING_SCAN_KEY);
clearPendingScanAfterAuth();
setAuthError(e instanceof Error ? e.message : '微信授权失败'); setAuthError(e instanceof Error ? e.message : '微信授权失败');
setAuthLoading(false); setAuthLoading(false);
} }
} }
const store = dash?.store as Record<string, unknown> | undefined; const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || []; const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const status = String(store?.status || ''); const status = String(store?.status || '');
const open = status === 'OPEN'; const open = status === 'OPEN';
const hoursParts: string[] = []; const hoursParts: string[] = [];
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`); if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00'; const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00';
const statusText = const statusText =
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店'; status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
return ( return (
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page"> <PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
<header className="shop-home-header"> <header className="shop-home-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
<div className="shop-home-content"> <div className="shop-home-content">
<section className="shop-home-hero"> <section className="shop-home-hero">
<div className="shop-home-hero-store"> <div className="shop-home-hero-store">
<span className="material-symbols-outlined shop-fill-icon">store</span> <span className="material-symbols-outlined shop-fill-icon">store</span>
<h2>{String(store?.name || '门店')}</h2> <h2>{String(store?.name || '门店')}</h2>
</div> </div>
<div className="shop-home-stats"> <div className="shop-home-stats">
<div className="shop-home-stat"> <div className="shop-home-stat">
<p className="shop-home-stat-label"></p> <p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p> <p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
<p className="shop-home-stat-sub"> <p className="shop-home-stat-sub">
{Number(dash?.todayScanCount || 0)} · {Number(dash?.todayPhoneCount || 0)} {Number(dash?.todayScanCount || 0)} · {Number(dash?.todayPhoneCount || 0)}
</p> </p>
</div> </div>
<div className="shop-home-stat"> <div className="shop-home-stat">
<p className="shop-home-stat-label"></p> <p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value"> <p className="shop-home-stat-value">
<span style={{ fontSize: 18 }}>¥</span> <span style={{ fontSize: 18 }}>¥</span>
{formatMoney(Number(dash?.todayAmount || 0))} {formatMoney(Number(dash?.todayAmount || 0))}
</p> </p>
</div> </div>
</div> </div>
</section> </section>
<section className="shop-home-scan"> <section className="shop-home-scan">
<button <button
type="button" type="button"
className="shop-home-scan-btn" className="shop-home-scan-btn"
disabled={scanning} disabled={scanning}
onClick={() => void handleScan()} onClick={() => void handleScan()}
> >
<span className="material-symbols-outlined">qr_code_scanner</span> <span className="material-symbols-outlined">qr_code_scanner</span>
</button> </button>
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p> <p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>} {scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
<Link to="/redeem/phone" className="shop-home-phone-link"> <Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span> <span className="material-symbols-outlined">smartphone</span>
</Link> </Link>
</section> </section>
<section className="shop-home-status"> <section className="shop-home-status">
<div className="shop-home-status-left"> <div className="shop-home-status-left">
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}> <div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
<span className="material-symbols-outlined shop-fill-icon">schedule</span> <span className="material-symbols-outlined shop-fill-icon">schedule</span>
</div> </div>
<div> <div>
<p className="shop-home-status-title"></p> <p className="shop-home-status-title"></p>
<p className="shop-home-status-sub">{statusText}</p> <p className="shop-home-status-sub">{statusText}</p>
<p className="shop-home-status-sub">: {hoursText}</p> <p className="shop-home-status-sub">: {hoursText}</p>
</div> </div>
</div> </div>
<label className="shop-home-switch" onClick={() => navigate('/status')}> <label className="shop-home-switch" onClick={() => navigate('/status')}>
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} /> <input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
<span className="shop-home-switch-track" /> <span className="shop-home-switch-track" />
</label> </label>
</section> </section>
<section> <section>
<div className="shop-home-records-head"> <div className="shop-home-records-head">
<h3 className="shop-home-records-title"></h3> <h3 className="shop-home-records-title"></h3>
<Link to="/records" className="shop-home-records-link"> <Link to="/records" className="shop-home-records-link">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link> </Link>
</div> </div>
<div className="shop-home-record-list"> <div className="shop-home-record-list">
{recent.length === 0 && ( {recent.length === 0 && (
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}></p> <p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}></p>
)} )}
{recent.map((r) => ( {recent.map((r) => (
<div key={String(r.id)} className="shop-home-record-item"> <div key={String(r.id)} className="shop-home-record-item">
<div> <div>
<p className="shop-home-record-time"></p> <p className="shop-home-record-time"></p>
<p className="shop-home-record-value"> <p className="shop-home-record-value">
{new Date(String(r.createdAt)).toLocaleString('zh-CN')} {new Date(String(r.createdAt)).toLocaleString('zh-CN')}
</p> </p>
</div> </div>
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p> <p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
</div> </div>
))} ))}
</div> </div>
</section> </section>
</div> </div>
<WechatScanAuthModal <WechatScanAuthModal
open={authModalOpen} open={authModalOpen}
mode={authModalMode}
loading={authLoading} loading={authLoading}
error={authError} error={authError}
onAuthorize={() => void startWechatAuth()} onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false); onRefresh={() => {
setAuthError('');
sessionStorage.removeItem(PENDING_SCAN_KEY); window.location.reload();
}} }}
onCancel={() => {
setAuthModalOpen(false);
setAuthModalMode('bind');
setAuthError('');
clearPendingScanAfterAuth();
pendingScanStartedRef.current = false;
}}
/> />
</PullToRefresh> </PullToRefresh>
); );
} }
+15 -5
View File
@@ -18,7 +18,7 @@ import {
consumeShopWechatLoginHint, consumeShopWechatLoginHint,
fetchClientConfig, fetchClientConfig,
formatShopWechatError, formatShopWechatError,
handleShopWechatCallback, handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
loginShopWithWechat, loginShopWithWechat,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
@@ -88,7 +88,7 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return; if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallback() void handleShopWechatCallbackOnce()
.then((result) => { .then((result) => {
if (!result) return; if (!result) return;
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
@@ -140,18 +140,28 @@ export default function LoginPage() {
async function login() { async function login() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
const trimmedCode = code.trim();
if (!trimmedPhone) {
setMsg('phone should not be empty');
return;
}
if (!trimmedCode) {
setMsg('code should not be empty');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', { const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
}); });
saveRememberedSession(data); saveRememberedSession(data);
applySession(data); applySession(data);
if (isWechatEnv() && wxAuthorize) { if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
setMsg('登录成功,正在关联微信…'); setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin(); await bindShopWechatAfterSmsLogin(data);
return; return;
} }
routeAfterShopLogin(data, navigate); routeAfterShopLogin(data, navigate);
+2 -2
View File
@@ -9,7 +9,7 @@ import {
authorizeShopWechat, authorizeShopWechat,
fetchClientConfig, fetchClientConfig,
fetchShopAccount, fetchShopAccount,
handleShopWechatCallback, handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin'; import { isWechatEnv } from '../lib/weixin';
@@ -45,7 +45,7 @@ export default function MinePage() {
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return; if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
void handleShopWechatCallback() void handleShopWechatCallbackOnce()
.then((result) => { .then((result) => {
if (!result) return; if (!result) return;
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
+13 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types'; import type { StorePackagesResponse } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import ShopPackagesForm from '../components/ShopPackagesForm'; import ShopPackagesForm from '../components/ShopPackagesForm';
import { request } from '../lib/api'; import { request } from '../lib/api';
@@ -30,7 +31,18 @@ export default function PackagesPage() {
: data.live?.length : data.live?.length
? data.live ? data.live
: [emptyPackage()]; : [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))); setItems(
base.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
}),
);
setPending(data.pendingRequest ?? null); setPending(data.pendingRequest ?? null);
setMsg(''); setMsg('');
} catch (e) { } catch (e) {
+16 -5
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import {
needsStoreSelection, needsStoreSelection,
@@ -10,6 +11,14 @@ import {
type ShopStoreOption, type ShopStoreOption,
} from '../lib/api'; } from '../lib/api';
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat('/');
return;
}
navigate('/', { replace: true });
}
export default function SelectStorePage() { export default function SelectStorePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession, store, authenticated } = useStoreSession(); const { applySession, store, authenticated } = useStoreSession();
@@ -37,7 +46,7 @@ export default function SelectStorePage() {
async function onSelect(storeId: string) { async function onSelect(storeId: string) {
if (loadingId) return; if (loadingId) return;
if (storeId === currentStoreId) { if (storeId === currentStoreId) {
navigate('/', { replace: true }); goShopHome(navigate);
return; return;
} }
setLoadingId(storeId); setLoadingId(storeId);
@@ -45,7 +54,7 @@ export default function SelectStorePage() {
try { try {
const session = await selectStore(storeId); const session = await selectStore(storeId);
applySession(session); applySession(session);
navigate('/', { replace: true }); goShopHome(navigate);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败'); setMsg(e instanceof Error ? e.message : '选店失败');
} finally { } finally {
@@ -166,9 +175,11 @@ export function routeAfterShopLogin(
session: ShopSessionPayload, session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void, navigate: (path: string, opts?: { replace?: boolean }) => void,
) { ) {
if (needsStoreSelection(session)) { const path = needsStoreSelection(session) ? '/select-store' : '/';
navigate('/select-store', { replace: true }); // iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
return; return;
} }
navigate('/', { replace: true }); navigate(path, { replace: true });
} }
-2
View File
@@ -80,7 +80,6 @@ export default function WithdrawPage() {
const canApply = const canApply =
!!summary?.isPrimary && !!summary?.isPrimary &&
summary.availableAmount > 0 && summary.availableAmount > 0 &&
!summary.hasPendingRequest &&
summary.hasBankAccount && summary.hasBankAccount &&
!submitting; !submitting;
@@ -126,7 +125,6 @@ export default function WithdrawPage() {
info info
</span> </span>
¥{formatMoney(summary?.dailyLimit ?? 5000)} ¥{formatMoney(summary?.dailyLimit ?? 5000)}
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
{!summary?.hasBankAccount ? ' · 请先完善收款账户' : ''} {!summary?.hasBankAccount ? ' · 请先完善收款账户' : ''}
</p> </p>
</section> </section>
+1
View File
@@ -794,6 +794,7 @@
.shop-scan-auth-actions { .shop-scan-auth-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
+2 -1
View File
@@ -12,7 +12,8 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: true,
port: 5174, port: 5174,
proxy: { '/api': 'http://localhost:3000' }, proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
}, },
}); });
@@ -1,6 +1,10 @@
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types'; import { useEffect, useState } from 'react';
import { track } from '../lib/analytics'; import { track } from '../lib/analytics';
import { openWecomCustomerService } from '../lib/customer-service'; import {
getCustomerServicePhone,
loadCustomerServicePhone,
openWecomCustomerService,
} from '../lib/customer-service';
type ContactCustomerSheetProps = { type ContactCustomerSheetProps = {
orderId?: string; orderId?: string;
@@ -9,7 +13,13 @@ type ContactCustomerSheetProps = {
}; };
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) { export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); const [phone, setPhone] = useState(getCustomerServicePhone);
useEffect(() => {
void loadCustomerServicePhone().then(setPhone);
}, []);
const tel = phone.replace(/-/g, '');
function openPhone() { function openPhone() {
track('cs_contact', { type: 'phone', orderId }); track('cs_contact', { type: 'phone', orderId });
@@ -41,7 +51,7 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
</div> </div>
<div className="contact-customer-option-body"> <div className="contact-customer-option-body">
<p className="contact-customer-option-title"></p> <p className="contact-customer-option-title"></p>
<p className="contact-customer-option-sub">{CUSTOMER_SERVICE_PHONE}</p> <p className="contact-customer-option-sub">{phone}</p>
</div> </div>
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span> <span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
</button> </button>
+8
View File
@@ -1,3 +1,5 @@
import { reportApiError } from '@dukang/client-logging';
export const BRAND = { export const BRAND = {
red: '#A02D30', red: '#A02D30',
yellow: '#FFC107', yellow: '#FFC107',
@@ -83,6 +85,12 @@ async function rawRequest<T>(
if (json.code !== 0) { if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number }; const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code; err.status = json.code;
if (json.code === 400) {
reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
);
}
throw err; throw err;
} }
return json.data as T; return json.data as T;
+20
View File
@@ -1,12 +1,31 @@
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types'; import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
import { fetchClientConfig } from './pay-wechat';
import { isWechatEnv } from './weixin'; import { isWechatEnv } from './weixin';
let cachedPhone = CUSTOMER_SERVICE_PHONE;
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */ /** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
export function getCustomerServiceWecomUrl(): string { export function getCustomerServiceWecomUrl(): string {
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim(); const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
return fromEnv || CUSTOMER_SERVICE_WECOM_URL; return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
} }
export function getCustomerServicePhone(): string {
return cachedPhone;
}
/** 从系统设置拉取客服电话(失败则保持默认常量) */
export async function loadCustomerServicePhone(): Promise<string> {
try {
const cfg = await fetchClientConfig();
const phone = cfg.customerServicePhone?.trim();
if (phone) cachedPhone = phone;
} catch {
/* keep fallback */
}
return cachedPhone;
}
/** /**
* *
* @returns true false * @returns true false
@@ -20,4 +39,5 @@ export function openWecomCustomerService(): boolean {
return true; return true;
} }
/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */
export { CUSTOMER_SERVICE_PHONE }; export { CUSTOMER_SERVICE_PHONE };
+13 -1
View File
@@ -1,4 +1,9 @@
import { apiBase } from './api'; import { apiBase } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
@@ -15,8 +20,15 @@ export async function uploadFileToOss(
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
let prepared = file;
if (mediaType === 'IMAGE') {
prepared = await compressImageFileIfNeeded(file);
if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+14 -3
View File
@@ -1,11 +1,22 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader'; import SubPageHeader from '../components/SubPageHeader';
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service'; import {
getCustomerServicePhone,
loadCustomerServicePhone,
openWecomCustomerService,
} from '../lib/customer-service';
import { track } from '../lib/analytics'; import { track } from '../lib/analytics';
export default function CustomerServicePage() { export default function CustomerServicePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); const [phone, setPhone] = useState(getCustomerServicePhone);
useEffect(() => {
void loadCustomerServicePhone().then(setPhone);
}, []);
const tel = phone.replace(/-/g, '');
function openOnline() { function openOnline() {
track('cs_contact', { type: 'wecom_kf' }); track('cs_contact', { type: 'wecom_kf' });
@@ -27,7 +38,7 @@ export default function CustomerServicePage() {
<a className="customer-service-phone-link" href={`tel:${tel}`}> <a className="customer-service-phone-link" href={`tel:${tel}`}>
<span className="material-symbols-outlined">call</span> <span className="material-symbols-outlined">call</span>
{CUSTOMER_SERVICE_PHONE} {phone}
</a> </a>
</div> </div>
</div> </div>
+2 -1
View File
@@ -12,8 +12,9 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: true,
proxy: { proxy: {
'/api': 'http://localhost:3000', '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
}, },
}, },
}); });
+2 -2
View File
@@ -16,7 +16,7 @@ export default defineConfig(async () => ({
plugins: ['@tarojs/plugin-html'], plugins: ['@tarojs/plugin-html'],
defineConstants: { defineConstants: {
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */ /** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3000'), TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3010'),
}, },
copy: { copy: {
patterns: [], patterns: [],
@@ -44,7 +44,7 @@ export default defineConfig(async () => ({
host: '0.0.0.0', host: '0.0.0.0',
proxy: { proxy: {
'/api': { '/api': {
target: process.env.VITE_API_TARGET ?? 'http://localhost:3000', target: process.env.VITE_API_TARGET ?? 'http://localhost:3010',
changeOrigin: true, changeOrigin: true,
}, },
}, },
+1 -1
View File
@@ -5,7 +5,7 @@ function resolveApiBase(): string {
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
? TARO_APP_API_ORIGIN ? TARO_APP_API_ORIGIN
: process.env.TARO_ENV === 'h5' : process.env.TARO_ENV === 'h5'
? 'http://localhost:3000' ? 'http://localhost:3010'
: ''; : '';
if (origin) { if (origin) {
return `${origin.replace(/\/$/, '')}/api/v1`; return `${origin.replace(/\/$/, '')}/api/v1`;
+2 -2
View File
@@ -21,7 +21,7 @@ pnpm build:mini-user:weapp
| 环节 | 文件 | 说明 | | 环节 | 文件 | 说明 |
|------|------|------| |------|------|------|
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000``build:weapp``--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 | | **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3010``build:weapp``--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
| **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` | | **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` |
### 微信登录 `invalid code` ### 微信登录 `invalid code`
@@ -33,7 +33,7 @@ pnpm build:mini-user:weapp
| 小程序 appid | `project.config.json``wxda31c8e8e85051e7` | | 小程序 appid | `project.config.json``wxda31c8e8e85051e7` |
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) | | 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock **本地联调(不接真实微信)**:保持默认即可(API → `localhost:3010`),并开启 Mock
```bash ```bash
# 终端 1 # 终端 1
+1 -1
View File
@@ -11,7 +11,7 @@ const isDevMode =
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */ /** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
const API_ORIGIN = const API_ORIGIN =
process.env.VITE_API_TARGET ?? process.env.VITE_API_TARGET ??
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com'); (isDevMode ? 'http://localhost:3010' : 'https://api.dukanghaoke.com');
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json')); const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/mini-user", "name": "@dukang/mini-user",
"version": "0.1.0", "version": "3.4.15",
"private": true, "private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)", "description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": { "scripts": {
+2
View File
@@ -6,11 +6,13 @@ export default defineAppConfig({
'pages/mine/index', 'pages/mine/index',
'pages/product-detail/index', 'pages/product-detail/index',
'pages/store-detail/index', 'pages/store-detail/index',
'pages/store-package-detail/index',
'pages/order-confirm/index', 'pages/order-confirm/index',
'pages/order-confirm-pickup/index', 'pages/order-confirm-pickup/index',
'pages/pay/index', 'pages/pay/index',
'pages/orders/index', 'pages/orders/index',
'pages/order-detail/index', 'pages/order-detail/index',
'pages/order-logistics/index',
'pages/pickup-receive/index', 'pages/pickup-receive/index',
'pages/addresses/index', 'pages/addresses/index',
'pages/address-edit/index', 'pages/address-edit/index',
+2
View File
@@ -6,11 +6,13 @@ import WechatShareBootstrap from './components/WechatShareBootstrap';
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks'; import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm'; import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
import { installClientErrorReporting } from './lib/client-error'; import { installClientErrorReporting } from './lib/client-error';
import { prefetchShareBrandAssets } from './lib/wechat-share';
import './app.css'; import './app.css';
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime // H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
patchTaroH5Hooks(); patchTaroH5Hooks();
installClientErrorReporting(); installClientErrorReporting();
prefetchShareBrandAssets();
function App({ children }: PropsWithChildren) { function App({ children }: PropsWithChildren) {
const handlingRef = useRef(false); const handlingRef = useRef(false);
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,44 @@
import { Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { splitLogisticsText } from '../lib/order-logistics-utils';
import { toast } from '../lib/api';
type LogisticsRichTextProps = {
text: string;
className?: string;
phoneClassName?: string;
};
export default function LogisticsRichText({
text,
className = '',
phoneClassName = 'order-logistics-phone-link',
}: LogisticsRichTextProps) {
const segments = splitLogisticsText(text);
function dial(phone: string) {
const normalized = phone.replace(/-/g, '');
Taro.makePhoneCall({ phoneNumber: normalized }).catch(() => toast('无法拨打电话'));
}
return (
<Text className={className}>
{segments.map((segment, index) =>
segment.type === 'phone' ? (
<Text
key={`${segment.value}-${index}`}
className={phoneClassName}
onClick={(e) => {
e.stopPropagation();
dial(segment.value);
}}
>
{segment.value}
</Text>
) : (
<Text key={`${index}-text`}>{segment.value}</Text>
),
)}
</Text>
);
}
@@ -1,21 +1,40 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Image, Swiper, SwiperItem } from '@tarojs/components'; import { View, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro from '@tarojs/taro';
type ProductCarouselProps = { type ProductCarouselProps = {
images: string[]; images: string[];
alt: string; alt: string;
variant?: 'home' | 'detail' | 'store'; variant?: 'home' | 'detail' | 'store';
previewable?: boolean;
/** cover=aspectFill 裁剪铺满;contain=aspectFit 缩放完整显示(门店门头固定区) */
imageFit?: 'cover' | 'contain';
}; };
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */ /** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
export default function ProductCarousel({ images, alt, variant = 'detail' }: ProductCarouselProps) { export default function ProductCarousel({
images,
alt,
variant = 'detail',
previewable = false,
imageFit = 'cover',
}: ProductCarouselProps) {
const slides = images.length > 0 ? images : ['']; const slides = images.length > 0 ? images : [''];
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const prefix = const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel'; variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
const isContain = imageFit === 'contain';
const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}`;
function previewAt(index: number) {
const urls = slides.filter(Boolean);
if (!urls.length) return;
const current = slides[index] || urls[0];
Taro.previewImage({ current, urls }).catch(() => undefined);
}
return ( return (
<View className={`${prefix}-wrap`}> <View className={wrapClass}>
<Swiper <Swiper
className={prefix} className={prefix}
circular={slides.length > 1} circular={slides.length > 1}
@@ -24,7 +43,13 @@ export default function ProductCarousel({ images, alt, variant = 'detail' }: Pro
{slides.map((src, index) => ( {slides.map((src, index) => (
<SwiperItem key={`${src}-${index}`} className={`${prefix}-item`}> <SwiperItem key={`${src}-${index}`} className={`${prefix}-item`}>
{src ? ( {src ? (
<Image className={`${prefix}-image`} src={src} mode="aspectFill" alt={alt} /> <Image
className={`${prefix}-image`}
src={src}
mode={isContain ? 'aspectFit' : 'aspectFill'}
alt={alt}
onClick={previewable ? () => previewAt(index) : undefined}
/>
) : ( ) : (
<View className={`${prefix}-placeholder`} /> <View className={`${prefix}-placeholder`} />
)} )}
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav'; import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api'; import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo'; import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { initClientVersionChecks } from '../lib/client-version';
import { saveWechatLoginResult } from '../lib/pay-wechat'; import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share'; import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth'; import { handleWechatAuthCallback } from '../lib/wechat-auth';
@@ -38,6 +39,7 @@ export default function WechatShareBootstrap() {
useEffect(() => { useEffect(() => {
void capturePromoSceneAndTouchScan(); void capturePromoSceneAndTouchScan();
initClientVersionChecks();
}, []); }, []);
useEffect(() => { useEffect(() => {
+8 -1
View File
@@ -3,13 +3,14 @@ import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav'; import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session'; import { resetStoresSessionBootstrap } from './stores-session';
import { resetHomeCatalogBootstrap } from './home-catalog-session'; import { resetHomeCatalogBootstrap } from './home-catalog-session';
import { reportClientValidationError } from './client-error';
function resolveApiBase(): string { function resolveApiBase(): string {
const origin = const origin =
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
? TARO_APP_API_ORIGIN ? TARO_APP_API_ORIGIN
: process.env.TARO_ENV === 'h5' : process.env.TARO_ENV === 'h5'
? 'http://localhost:3000' ? 'http://localhost:3010'
: ''; : '';
if (origin) { if (origin) {
return `${origin.replace(/\/$/, '')}/api/v1`; return `${origin.replace(/\/$/, '')}/api/v1`;
@@ -113,6 +114,12 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
throw new Error('接口不可达,请确认 API 服务已启动'); throw new Error('接口不可达,请确认 API 服务已启动');
} }
if (status >= 400 || body.code !== 0) { if (status >= 400 || body.code !== 0) {
if (status === 400 || body?.code === 400) {
reportClientValidationError({
message: body?.message || `请求失败(${status})`,
apiPath: path,
});
}
throw new Error(body?.message || `请求失败(${status})`); throw new Error(body?.message || `请求失败(${status})`);
} }
return (res.data as { data: T }).data; return (res.data as { data: T }).data;
+68
View File
@@ -0,0 +1,68 @@
import {
BRAND_LOGO_MARK_URL,
BRAND_LOGO_URL,
BRAND_LOGO_WIDE_URL,
CUSTOMER_SERVICE_PHONE,
QUALIFICATION_DISCLOSURE_URL,
type ClientRuntimeConfig,
} from '@dukang/shared-types';
import { fetchClientConfig } from './pay-wechat';
export type BrandAssets = {
brandLogoUrl: string;
brandLogoWideUrl: string;
brandLogoMarkUrl: string;
qualificationDisclosureUrl: string;
customerServicePhone: string;
};
const FALLBACK: BrandAssets = {
brandLogoUrl: BRAND_LOGO_URL,
brandLogoWideUrl: BRAND_LOGO_WIDE_URL,
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
customerServicePhone: CUSTOMER_SERVICE_PHONE,
};
let cached: BrandAssets | null = null;
let inflight: Promise<BrandAssets> | null = null;
function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets {
return {
brandLogoUrl: config?.brandLogoUrl?.trim() || FALLBACK.brandLogoUrl,
brandLogoWideUrl: config?.brandLogoWideUrl?.trim() || FALLBACK.brandLogoWideUrl,
brandLogoMarkUrl: config?.brandLogoMarkUrl?.trim() || FALLBACK.brandLogoMarkUrl,
qualificationDisclosureUrl:
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
};
}
/** 同步读取最近一次缓存(未拉取前返回代码默认常量) */
export function getBrandAssetsSync(): BrandAssets {
return cached ?? FALLBACK;
}
/** 拉取 /common/client-config 中的品牌与客服配置并缓存 */
export async function loadBrandAssets(force = false): Promise<BrandAssets> {
if (!force && cached) return cached;
if (!force && inflight) return inflight;
inflight = fetchClientConfig()
.then((cfg) => {
cached = fromConfig(cfg);
return cached;
})
.catch(() => {
cached = FALLBACK;
return cached;
})
.finally(() => {
inflight = null;
});
return inflight;
}
export function applyBrandFromClientConfig(config: ClientRuntimeConfig | null | undefined) {
cached = fromConfig(config);
return cached;
}
+12
View File
@@ -6,6 +6,7 @@ export type ClientErrorCategory =
| 'js_error' | 'js_error'
| 'unhandled_rejection' | 'unhandled_rejection'
| 'api_error' | 'api_error'
| 'validation_error'
| 'network' | 'network'
| 'render' | 'render'
| 'bridge' | 'bridge'
@@ -57,6 +58,17 @@ export function reportClientError(payload: ClientErrorPayload): void {
}).catch(() => {}); }).catch(() => {});
} }
/** 上报 API 400 验证错误(失败静默) */
export function reportClientValidationError(input: { message: string; apiPath?: string }): void {
if (input.apiPath?.includes('/common/client-errors')) return;
reportClientError({
level: 'warn',
category: 'validation_error',
message: input.message,
extra: { status: 400, url: input.apiPath },
});
}
let installed = false; let installed = false;
/** 安装小程序/H5 全局未捕获错误钩子(幂等) */ /** 安装小程序/H5 全局未捕获错误钩子(幂等) */
+131
View File
@@ -0,0 +1,131 @@
import Taro from '@tarojs/taro';
import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
export const APP_VERSION = '3.4.15';
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
function parseSemver(v: string): number[] {
return v.split('.').map((n) => parseInt(n, 10) || 0);
}
export function compareSemver(a: string, b: string): number {
const pa = parseSemver(a);
const pb = parseSemver(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const da = pa[i] ?? 0;
const db = pb[i] ?? 0;
if (da !== db) return da - db;
}
return 0;
}
function exitMiniProgramIfSupported() {
if (process.env.TARO_ENV !== 'weapp') return;
if (typeof Taro.exitMiniProgram !== 'function') return;
void Taro.exitMiniProgram({});
}
/** 微信 CDN 有新包时引导 applyUpdate;返回是否已触发更新流程 */
function tryWeappForceUpdate(min: string): Promise<boolean> {
if (process.env.TARO_ENV !== 'weapp' || typeof Taro.getUpdateManager !== 'function') {
return Promise.resolve(false);
}
return new Promise((resolve) => {
const manager = Taro.getUpdateManager();
let settled = false;
const finish = (updated: boolean) => {
if (settled) return;
settled = true;
resolve(updated);
};
manager.onCheckForUpdate((res) => {
if (!res.hasUpdate) {
finish(false);
return;
}
manager.onUpdateReady(() => {
void Taro.showModal({
title: '版本过低',
content: `当前 ${APP_VERSION_LABEL} 低于要求版本 v${min},已检测到新版本,请立即更新后继续使用。`,
showCancel: false,
confirmText: '立即更新',
success: (r) => {
if (r.confirm) {
manager.applyUpdate();
finish(true);
} else {
finish(false);
}
},
fail: () => finish(false),
});
});
manager.onUpdateFailed(() => finish(false));
});
setTimeout(() => finish(false), 4000);
});
}
async function enforceMinClientVersion(min: string) {
const updated = await tryWeappForceUpdate(min);
if (updated) return;
await Taro.showModal({
title: '版本过低',
content: `当前 ${APP_VERSION_LABEL} 低于要求版本 v${min},请更新至最新版本后继续使用。`,
showCancel: false,
confirmText: '我知道了',
});
exitMiniProgramIfSupported();
}
export async function checkClientVersionGate() {
try {
const config = await fetchClientConfig();
const min = config.minClientVersion?.trim();
if (min && compareSemver(APP_VERSION, min) < 0) {
await enforceMinClientVersion(min);
}
} catch {
/* 配置拉取失败不阻塞启动 */
}
}
export function setupWeappUpdateManager() {
if (process.env.TARO_ENV !== 'weapp') return;
if (typeof Taro.getUpdateManager !== 'function') return;
const manager = Taro.getUpdateManager();
manager.onCheckForUpdate((res) => {
if (!res.hasUpdate) return;
manager.onUpdateReady(() => {
void Taro.showModal({
title: '更新提示',
content: '新版本已准备好,是否重启应用?',
confirmText: '立即更新',
success: (r) => {
if (r.confirm) manager.applyUpdate();
},
});
});
manager.onUpdateFailed(() => {
void Taro.showModal({
title: '更新失败',
content: '新版本下载失败,请删除小程序后重新搜索打开。',
showCancel: false,
});
});
});
}
export function initClientVersionChecks() {
setupWeappUpdateManager();
void checkClientVersionGate();
}
+34
View File
@@ -0,0 +1,34 @@
import Taro from '@tarojs/taro';
const MAX_BYTES = 10 * 1024 * 1024;
/** 小程序临时文件超过 10MB 时用 compressImage 压缩后再上传 */
export async function compressWeappImageIfNeeded(tempFilePath: string): Promise<string> {
if (process.env.TARO_ENV !== 'weapp') return tempFilePath;
let path = tempFilePath;
let size = await getFileSize(path);
if (size <= MAX_BYTES) return path;
const qualities = [80, 65, 50, 40];
for (const quality of qualities) {
const res = await Taro.compressImage({ src: path, quality });
path = res.tempFilePath;
size = await getFileSize(path);
if (size <= MAX_BYTES) return path;
}
if (size > MAX_BYTES) {
throw new Error('图片压缩后仍超过 10MB,请换一张较小的图片');
}
return path;
}
async function getFileSize(filePath: string): Promise<number> {
try {
const info = await Taro.getFileInfo({ filePath });
return typeof info.size === 'number' ? info.size : 0;
} catch {
return 0;
}
}

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