108 Commits

Author SHA1 Message Date
jacy 0fb7ab7abb Merge pull request 'v3.4.18' (#17) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/17
2026-08-17 23:45:19 +08:00
jacy 905e145af3 Merge pull request 'v3.4.18' (#16) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/16
2026-08-17 23:45:00 +08:00
jacy 7dd5fdfb12 优化门店端结算页面的UI
CI / verify (pull_request) Has been cancelled
2026-08-17 23:43:45 +08:00
jacy 48d6900ab6 fix(iam): 合伙人登录双拦截——子账号status=DISABLED 与 主账号bindingStatus=PAUSED 均拒绝登录
CI / verify (push) Has been cancelled
2026-08-17 23:39:53 +08:00
jacy 5ca9aa00aa fix(h5-partner): 微信OAuth回跳错误不再吞没,DISABLED提示可见
CI / verify (push) Has been cancelled
- 合伙人端 PartnerSessionContext 微信回跳 catch 原把后端
  「该账号已暂停使用,请联系客服人员」错误整体吞掉,导致
  DISABLED 账号微信登录无任何反馈
- 改为用 toastError 显式提示,与短信路径一致(微信同理)

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 21:36:28 +08:00
jacy da6a38daa4 merge(dev): fix batch image upload persistence
CI / verify (push) Has been cancelled
2026-08-07 21:13:45 +08:00
jacy d6d4a77473 merge(dev): local API port 3010
CI / verify (push) Has been cancelled
2026-08-07 21:04:06 +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 3b192b0b88 merge(dev): store sortOrder and package gallery swipe
CI / verify (push) Has been cancelled
2026-08-07 19:58:53 +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 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 713dce52b4 merge(dev): parse XFX multipart track callbacks
CI / verify (push) Has been cancelled
2026-08-07 19:08:15 +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 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 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 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 1a615ffe93 merge(dev): harden iOS WeChat shop scan
CI / verify (push) Has been cancelled
2026-08-07 13:08:22 +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 b32c68d56b merge(dev): fix XFX track callback body parse
CI / verify (push) Has been cancelled
2026-08-06 17:51:04 +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 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 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 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 33b868dfac merge(dev): ticket center status Chinese labels
CI / verify (push) Has been cancelled
2026-08-06 00:23:43 +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 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 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 69f52921a7 merge(dev): v3.4.13 logistics enhancements
CI / verify (push) Has been cancelled
2026-08-05 23:34:20 +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 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 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 dc4295d1bd merge(dev): v3.4.13 experience optimizations
CI / verify (push) Has been cancelled
2026-08-05 22:34:44 +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 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 f5e523a425 merge(dev): mini-user store list card redesign
CI / verify (push) Has been cancelled
2026-08-05 00:11:34 +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 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 1c787ed576 merge(dev): release v3.4.12
CI / verify (push) Has been cancelled
2026-08-04 23:53:48 +08:00
jacy aca3df4bf2 merge(dev): release v3.4.11 dev plan, wecom push and bot fixes
CI / verify (push) Has been cancelled
2026-08-04 22:41:49 +08:00
jacy fa0d928980 merge(dev): release v3.4.10
CI / verify (push) Has been cancelled
2026-08-03 22:54:23 +08:00
jacy 6f427cb553 merge(dev): persona logging and Sentry system config
CI / verify (push) Has been cancelled
2026-08-03 22:31:27 +08:00
jacy a7d55b7293 merge(dev): 走马灯滚出终点调整
CI / verify (push) Has been cancelled
2026-08-03 17:52:18 +08:00
jacy f09e00e16f merge(dev): 门店核销走马灯优化
CI / verify (push) Has been cancelled
2026-08-03 16:34:36 +08:00
jacy aa0e140fec merge(dev): 核销方式标记与统计
CI / verify (push) Has been cancelled
2026-08-03 16:21:47 +08:00
jacy f8d8e2d6d5 merge(dev): 统一门店结算账单
CI / verify (push) Has been cancelled
2026-08-03 16:04:55 +08:00
jacy 9859abba7e merge(dev): 全部门店可提现+企微提醒
CI / verify (push) Has been cancelled
2026-08-03 15:43:34 +08:00
jacy c89560e4f8 merge(dev): 门店手动提现 OPT-010
CI / verify (push) Has been cancelled
2026-08-03 15:32:17 +08:00
jacy ffed560d43 merge(dev): 小程序 Intl 兜底
CI / verify (push) Has been cancelled
2026-08-03 14:54:02 +08:00
jacy 5199b495e0 merge(dev): 门店核销走马灯兼容 H5/小程序
CI / verify (push) Has been cancelled
2026-08-03 14:48:41 +08:00
jacy 82b98aa4e3 merge(dev): 修复 Intl is not defined
CI / verify (push) Has been cancelled
2026-08-03 14:37:48 +08:00
jacy 7dffd40f3f merge(dev): 走马灯 JS 实现
CI / verify (push) Has been cancelled
2026-08-03 14:33:35 +08:00
jacy 0304201e80 merge(dev): 发版默认生产约定
CI / verify (push) Has been cancelled
2026-08-03 14:29:55 +08:00
jacy dd4362c397 merge(dev): 门店详情修复
CI / verify (push) Has been cancelled
2026-08-03 14:29:03 +08:00
jacy 87515decb3 merge(dev): 核销时间格式与门店走马灯
CI / verify (push) Has been cancelled
2026-08-03 14:20:36 +08:00
jacy 28ef916a22 merge(dev): 门店可见白名单
CI / verify (push) Has been cancelled
2026-08-03 13:58:47 +08:00
jacy 39e38aac7e merge(dev): mini-user eight fixes and catalog whitelist
CI / verify (push) Has been cancelled
2026-08-03 13:12:40 +08:00
jacy daf534b4db merge(dev): skip WeCom audit client-error noise
CI / verify (push) Has been cancelled
2026-08-02 21:52:18 +08:00
jacy 118993a01f merge(dev): deploy default DB sync
CI / verify (push) Has been cancelled
2026-08-02 18:12:37 +08:00
jacy 8f361981bf merge(dev): product fulfillment flags and auto SKU
CI / verify (push) Has been cancelled
2026-08-02 18:10:31 +08:00
jacy 4bb6ae20cd merge(dev): fix partner image picker after cancel
CI / verify (push) Has been cancelled
2026-08-02 17:11:31 +08:00
jacy ad757e9ba8 merge(dev): restore camera and fix mine benefit-detail back
CI / verify (push) Has been cancelled
2026-08-02 17:00:58 +08:00
jacy e63b57db19 merge(dev): client error reporting
CI / verify (push) Has been cancelled
2026-08-02 16:27:20 +08:00
jacy fb4432b307 merge(dev): WeCom webhook ops alerts
CI / verify (push) Has been cancelled
2026-08-02 16:02:35 +08:00
jacy ab44515e8b merge(dev_jacy): on-site pickup COMPLETED on pay
CI / verify (push) Has been cancelled
2026-08-02 13:42:17 +08:00
jacy 78365d648c merge(dev_jacy): proxy-order real pay
CI / verify (push) Has been cancelled
2026-08-02 13:28:16 +08:00
jacy e9eddbb26e merge(dev_jacy): shop WeChat unbound tip
CI / verify (push) Has been cancelled
2026-08-02 13:15:50 +08:00
jacy c00ca3620d merge(dev): partner dropdown label company-name
CI / verify (push) Has been cancelled
2026-08-02 13:07:41 +08:00
jacy 4d76ee6d0e merge(dev): WeChat login restore, proxy pay, staging SSL verify
CI / verify (push) Has been cancelled
2026-08-02 12:53:53 +08:00
jacy 2dbe665bdc Merge pull request '合并到main 创建8.1最后一波修改,后面测试和正式就会分离' (#11) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/11
2026-07-31 11:27:55 +08:00
jacy 1bc3bec977 Merge pull request 'Dev Main by v3.4 测试数据删除' (#10) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/10
2026-07-28 17:19:21 +08:00
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
76 changed files with 3438 additions and 779 deletions
+45
View File
@@ -0,0 +1,45 @@
# 2026-08-12 工作记录
## 杜康好客 · 三项功能需求落地(dev_jacy 分支)
### 需求1:管理端用户列表增加好客权益金额列
- 后端 `server/dukang-api/src/modules/ops/admin-users.service.ts`
- 新增常量 `BENEFIT_STAT_STATUSES = ['ACTIVE','USED_UP']``UserBenefitStat` 类型、`EMPTY_BENEFIT_STAT``toAmount()`
- `list()` / `detail()` 均调用新增私有方法 `loadBenefitStats(userIds)`,用 `benefitCoupon.groupBy({ by:['userId'], where:{status:{in:[...BENEFIT_STAT_STATUSES]}}, _sum:{totalAmount,usedAmount,balance} })` 批量聚合,避免 N+1。
- `mapAdminUserRow` 增加 `benefit` 参数并展开到返回对象。统计口径与 C 端 `listCoupons` 一致(排除 VOID)。
- 前端 `apps/admin-web/src/lib/api.ts``AdminUserRow` 增加 `benefitTotalAmount?/benefitUsedAmount?/benefitBalance?: number`
- 前端 `apps/admin-web/src/pages/UsersPage.tsx`:新增「剩余权益/已用权益/累计权益」三列(右对齐、可排序,scroll x 改为 1830),详情抽屉 Descriptions 增加「好客权益」项。
### 需求2:管理端门店入驻合同支持多图上传
- 新建共享工具 `server/dukang-api/src/common/store-media/contract-urls.util.ts``MAX_CONTRACT_FILES=20``normalizeContractUrls(urls?, legacy?)``contractMediaType(url)`.pdf→FILE 否则 IMAGE)。
- 后端 `admin-stores.service.ts`import 上述工具;create645 行)/ update451 行)路径由单条 `contractUrl` 改为遍历建立多条 `bizType:'CONTRACT'` 记录(`sortOrder:i``mediaType` 按扩展名判定)。
- DTO `admin-mutate.dto.ts``contractUrl?:string` 标记 `@deprecated`,新增 `contractUrls?:string[]``@IsArray @IsString({each:true}) @ArrayMaxSize(20)`)。
- 前端 `apps/admin-web/src/lib/storeCreate.ts``StoreCreateForm.contractUrl?:string``contractUrls?:string[]`
- `MultiImageUpload.tsx`:新增 `isPdf()``FilePdfOutlined`、PDF 缩略图卡片、`buttonText` prop。
- `StoresPage.tsx`:编辑抽屉 + 创建 step1 表单均改用 `MultiImageUpload`name=contractUrls, maxCount=20, accept=image/*,.pdf, buttonText="批量上传合同"),回显/保存 payload 改为 `contractUrls:string[]`
### 需求3:合伙人 H5 门店入驻合同支持多图上传
## 发布流水线(同一会话,22:45 后)
完整跑通 dukang-release 标准流程:`dev_jacy` → push → merge `dev` → merge `main` → 发生产。
- 提交 `8805335` feat(ops,store,partner): 用户权益列 + 门店合同多图上传(13 文件,356+/59-
- `dev_jacy` push → `origin/dev_jacy`(8805335)
- merge `dev_jacy``dev``491f03e`push `origin/dev`
- merge `dev``main``4a0e184`push `origin/main`
- `deploy/deploy-prod.sh -- --skip-db`(无 Prisma schema 变更,故 --skip-db)→ `DEPLOY_EXIT=0``==> 发版完成 (production)`
- 生产 PM2 全 online`dukang-api`(8090) / admin-web / h5-partner / h5-shop / h5-user;健康检查 `api:200` `mini-user(h5):200`;版本记录 commit=4a0e184
### 踩坑/注意
- `git checkout main` 时本机工作树出现 `server/dukang-api` 大量 `D`(误删)假象:实为 sandbox 下 checkout 未刷新工作树,非仓库损坏。用 `git checkout -f main` 重新填充即恢复;`origin/main` 始终正确(ls-remote 核对 4a0e184)。
- 提交/合并后 `git status` 报 "ahead of origin by N" 为本地 tracking ref 陈旧,非真实分歧(ls-remote 已核对远程三分支均含本次提交)。
- 仅提交 13 个需求文件 + 新建 `server/dukang-api/src/common/store-media/`,已排除 `.workbuddy/``fix_acl.ps1`
- 发布命令在 sandbox 下直接运行 SSH 部署成功(未强制禁用沙箱)。
- `apps/h5-partner/src/lib/storeDraft.ts``StoreDraftForm.contractUrl:string``contractUrls:string[]``defaultStoreForm` 改为 `contractUrls:[]`;新增 `MAX_CONTRACT_COUNT=20``normalizeStoreDraftForm` 新增 `contractUrls` 计算(兼容旧 `contractUrl` 迁移为数组,去重限 20);`validateStoreStep2` 参数/校验改为 `contractUrls`,不足 1 张返回「请上传签约合同」。
- `MultiOssUploadField.tsx`:新增 `accept?``unit?` props`isPdf()`PDF 缩略图卡片。
- `StoreCreatePage.tsx`:签约合同区块 `OssUploadField``MultiOssUploadField`value=`form.contractUrls ?? []`, maxCount=MAX_CONTRACT_COUNT, unit="个", accept="image/*,.pdf");提交体 `contractUrl``contractUrls`,新增 `const contractUrls = Array.from(new Set(...)).slice(0, MAX_CONTRACT_COUNT)`envPhotoUrls slice 上限由 3 改为 20。修正了渲染块误用未定义变量 `contractUrls`(已改为从 form 读取)。
### 验证
- 后端 `npx tsc --noEmit`server/dukang-api)通过,exit 0。
- admin-web / h5-partner 使用 `git stash` 基线对比法:本次改动**未引入任何新类型错误**(现存错误均为预存在基线问题:css module 声明、`import.meta.env`、过期 config 类型、TabLayout `end`、staff `staffRole` 等)。
- 向后兼容:旧 `contractUrl` 字段保留并标记 deprecated,H5 草稿旧数据自动迁移;避免破坏现有 API 客户端。
+79
View File
@@ -0,0 +1,79 @@
# 2026-08-16
## 新增:线上库同步本地 CLI 工具
- 文件:`deploy/sync-prod-db-to-local.sh`(新建,未提交)
- 用途:将生产库 `dukang_prod`(阿里云 RDS `rm-bp139andp1aa385i9.mysql.rds.aliyuncs.com:3306`,用户 `dukangadmin`)同步到本地 Docker MySQL `dukang_haoke`localhost:6016 / 容器内 `dukang-v1-mysql`root/root)。
- 原理:SSH 隧道借道 `dukang-server``-L 6018:rds:3306`)→ 本地无 mysql 客户端,故在 `dukang-v1-mysql` 容器内跑 mysqldump/mysql,经 `host.docker.internal:TUNNEL_PORT` 连隧道;管道直导,不落临时文件。
- 安全:默认先 `docker exec mysqldump` 备份本地到 `deploy/backups/`;需 `--yes` 跳过交互确认;`--dry-run` 只读远端 DATABASE_URL 并打印计划。
- 远端凭据从 `/opt/dukang/server/dukang-api/.env.production` 的 DATABASE_URL 运行时解析(密码经 `MYSQL_PWD` 环境变量传入,避免命令行暴露)。
- 已知限制:不支持密码含 `@`prod DB 名与本地不同(dukang_prod→dukang_haoke)靠 mysqldump 不带 `--databases` 实现库名映射。
- 状态:仅跑通 `--dry-run`,未执行真实同步(待用户确认,因会覆盖本地库并拉取生产数据)。
## 修复:首次同步失败的根因
- 失败现象:`mysqldump` 退出码 2,提示 `本地库可能处于不一致状态`(工具自身的失败兜底提示)。
- 真实根因有两条:
1. **密码解析错误**:生产 DB 密码含 `@` 字符(`passHasAt=true`)。原 bash 解析用「第一个 @」切分 userinfo/host,把密码里 `@` 之后的部分截断,导致 Access denied。
修复:改为在服务端用 `new URL()` 解析,base64 回传各字段(user/host/port/db/password),本地 `base64 -d` 还原,彻底免疫 `@`/`:`/`$` 等特殊字符。服务端 auth 测试 `authTestStatus=0` 验证密码正确。
2. **权限不足**`dukangadmin`(应用库用户)无 `RELOAD`/`FLUSH_TABLES` 权限。`--single-transaction` 仍会触发全局 `FLUSH TABLES` → Access denied。
修复:`mysqldump` 改用 `--lock-tables=0`(不发起 FTWRL+ `--set-gtid-purged=OFF`(RDS 启用 GTID,否则本地恢复会报 GTID_PURGED 错误),并保留 `--skip-routines --skip-events --column-statistics=0 --no-tablespaces --add-drop-table --skip-triggers --no-create-db`
- 验证:结构导出(--no-data)在 4 组 flag 中仅 `--lock-tables=0` / `--skip-lock-tables`(不含 --single-transaction)退出 0。
- 现工具已修正并实际执行同步(--yes,先自动备份本地到 deploy/backups/)。
- 注意:`--lock-tables=0` 无事务一致性快照;生产库在线写入期间导出可能有极小不一致,但恢复时 mysqldump 自带 `FOREIGN_KEY_CHECKS=0`,_dev 库可接受。若需完全一致快照,可给 dukangadmin 授 RELOAD 后用 --single-transaction。
## 修改:套餐审核导航样式恢复一致
- 文件:`apps/admin-web/src/layouts/AdminLayout.tsx``attachPackageAuditBadge``/store-package-audits` 项)。
- 诉求演变:用户先要求「文字恒为白色」→ 后改为「不要单独处理,保持与子菜单一致,只是多一个待审徽章图标」。
- 最终实现:label 改为 `<span style={{display:'inline-flex',gap:6}}>套餐审核{pendingCount>0 && <Badge count size="small"/>}</span>`。文字无强制颜色,随菜单主题继承 normal/hover/selected 颜色;徽章仅 pendingCount>0 时作为图标提示,不参与着色。
## 发布流水线(本次收尾,含之前未上线的 8+1 文件)
- 背景:此前「联系电话脱敏+套餐多行输入+同步工具」等 9 文件 commit `8de4179` 已 push dev_jacy/dev/main,但**生产发布失败**deploy.env 丢失导致 `未配置 DEPLOY_HOST`)。
- 修复发布:`deploy.env` 是 gitignore 的本地密钥文件,已丢失不可找回;改用 SSH 别名 `dukang-server`~/.ssh/configHostName 47.110.129.57 / User root / IdentityFile pem)覆盖:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`
- 关键排查:`origin/*` 本地缓存 ref 多次陈旧(误报 ahead/behind),必须以 `git ls-remote` 为准。本次真实远端:dev_jacy=8de4179、dev=c05cd0e、main=bac5c7a,与本地一致,无丢失。
- 本次提交:`bafb342` fix(admin) 套餐审核样式 → push dev_jacy → merge dev(`0bfc19b`) → merge main(`029fd69`) → push。
- 发布结果:`DEPLOY_EXIT=0``==> 发版完成 (production)`,健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `029fd69`
- 提示:以后若想恢复标准 `bash deploy/deploy-prod.sh`(不带 --host),需重建 `deploy/deploy.env`(从 `deploy.env.example` 复制,填入 DEPLOY_HOST=47.110.129.57 等;该文件 gitignore,勿提交)。
## 排查:h5-partner 本地 /login 调 /partner/me 报 401
- 现象:`http://localhost:5176/login` 启动后控制台报 `GET /api/v1/partner/me 401 (Unauthorized)`
- 结论:**不是 bug,是登录页的「过期 token 探测」**。链路:Vite 代理 `/api``localhost:3010`(正常);后端返回 `{"code":401,"message":"Missing token"}`(说明后端在线、代理通)。`ensureSession()`api.ts:244)只在 localStorage 有 token 时才打 `/partner/me`;无 token 直接返回未登录。所以 401 来自之前测试残留的失效 token → 前端静默清除并展示登录表单,不崩、不弹错。刷新页面(token 已清)即不再出现。
- 本地登录方式(关键,README 已过期):
- `MOCK_SMS=true`(.env),短信走 mock,不连运营商;固定码 `MOCK_SMS_FIXED_CODE` 默认 `999888`README 写的 123456 已不准)。
- README 的测试号 `13700000001` **不在本地同步库**;本地 `partner_account` 共 9 个,可用真实号如 `18049821889`(Jacy) / `13073729990`(queen)(均 ACTIVE)。
- 流程:填真实合伙人手机号 → 获取验证码 → **验证码打印在 `pnpm dev:api` 后端终端**`Mock SMS → 180****8889 code=XXXXXX`)→ 填码登录。
- 注意 h5-partner 默认 5175,本次因 5175 被占自动落到 5176,代理是路径匹配不受影响。
## 发布:v3.4.17 门店套餐审核快捷入口 上生产
- 提交 `1c91e6b`dev_jacy 上,含后端 admin-stores.service.ts + AdminStorePackagesSection/StorePackageAuditsPage/StoresPage 三前端 + 开发计划文档)。
- 流水线:直接快进推送(避免 git checkout 工作树不刷新):`git push origin dev_jacy` + `dev_jacy:dev` + `dev_jacy:main`,三分支统一到 `1c91e6b`;本地 `git branch -f dev/main origin/*` 同步。
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(后端仅给列表响应加字段,无 schema 变更,故 skip-db)。
- 结果:`DEPLOY_EXIT=0`,新 tag `v3.4.17`,全部应用构建通过,PM2 重启 dukang-api/h5-shop/h5-partner/admin-web(另拉起 h5-user),健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `1c91e6b`
## 发布:v3.4.18 套餐变更对比逐字段列出变动明细
- 需求:套餐变更对比「将变动的地方详细列出来」(之前只给「新增/删除/变更/未变」粗粒度标记,不列具体字段)。
- 改动仅在 `apps/admin-web/src/pages/StorePackageAuditsPage.tsx`
- 新增 `fieldChanges(live, proposed)`:逐字段比对 价格/套餐名称/菜品内容/可用时间/其他说明/图片,产出 `label+旧值→新值` 明细。
- `diffPackages` 给「变更」行附 `changes`
- `PackageDetailCard` 在「待审核」卡片底部渲染「变更明细」块(旧值删除线 → 新值加粗,橙色块)。
- 抽屉顶部加「新增 X · 删除 Y · 变更 Z · 未变 W」汇总条。
- 提交 `bbd7e52`feat(admin) 套餐变更对比逐字段列出变动明细),快进推送 dev_jacy/dev/main。
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(无 schema 变更)。
- 结果:构建通过、PM2 重启 admin-web 等、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `bbd7e52`(无精确 tag,记 tag=-,无害)。
- 已知局限:配对按「套餐名称」(空则索引) 位置匹配;若套餐被**改名**,会误判为「删除旧名+新增新名」而非「变更」,故名称变更不会出现在逐字段明细里。
## 优化:套餐变更对比·逐字差异 + 文档并入 v3.4.17
- 需求:变动明细要能**定位到文本中具体哪些字变了(逐字检查)**,且把开发计划文档合并到 v3.4.17 文档里(不再单独留 v3.4.18 文档)。
- 代码改动(仍仅 `StorePackageAuditsPage.tsx`):
- `diffText(a,b)`LCS 动态规划 (`dp[i][j]`)+回溯,产出 `equal/delete/insert` 段落并合并相邻同类型;`O(|a|·|b|)`,字段长度可控。
- `TextDiff({oldText,newText})`:两行渲染——「原:」红删线标被删字、「新:」绿标新增字。
- `FieldChange` 增加 `kind: 'text'|'value'`:价格/图片走 `旧→新`;套餐名称/菜品内容/可用时间/其他说明走 `TextDiff` 逐字高亮。
- `杜康好客-v3.4.17-门店套餐审核快捷入口.md` 新增第 F 节「套餐变更对比·逐字段/逐字明细」,并把原先 v3.4.18 的字段级说明并入,删去独立 v3.4.18 文档计划。
- 提交 `ecd9973`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`
- 结果:构建通过、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `ecd9973`tag=-,无害)。
- 验证要点:开任一「变更」套餐的待审核卡片 → 底部「变更明细」→ 菜品/说明等文本以红删+绿增逐字呈现。
## 优化:h5-partner 门店详情·门店套餐「编辑套餐」按钮
- 需求:按钮**缩小、靠最右侧、保持美观**。根因:`.partner-btn-outline` 是整行大按钮(`flex:1; height:48px; font-size:16px`),放在卡片标题右侧会被拉伸成大块。
- 改动:`apps/h5-partner/src/styles.css` 新增 `.partner-btn-outline--compact``flex:none; height:30px; padding:0 14px; font-size:13px; font-weight:500; border-radius:var(--radius-sm); color:var(--color-primary)`hover 用 `color-mix` 8% 底色);`StoreDetailPage.tsx:288` 的「编辑套餐」按钮加该类(父级 `space-between` 已保证贴右)。
- 提交 `69e201f`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`
- 结果:构建通过、PM2 重启 h5-partner、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `69e201f`tag=-,无害)。
- 注意:`StoreDetailPage.tsx:311` 有个历史遗留 TS 错误(`status === 'CLOSED'` 类型无重叠),与本改动无关,未动。
+70
View File
@@ -0,0 +1,70 @@
# 2026-08-17 工作日志
## 杜康好客 v3.4.18 门店体验与登录态优化(含 F 项)
### 已实现并发布
- **A** mini-user 门店座机脱敏:改 `apps/mini-user/src/lib/phone.ts``maskPhone` 座机分支,中间四位 `****`(保留区号+首2+末2)。
- **B** 套餐详情图 1/5 计数 + 自动轮播:`ProductCarousel.tsx` detail 变体加 autoplay(3.5s) + 右下角 `1/N` 计数。
- **C** 合伙人暂停禁止登录:`auth.service.ts` 三处非 ACTIVE 抛「该账号已暂停使用,请联系客服人员」;`h5-partner/LoginPage.tsx` 同步文案;复用 `DISABLED` 枚举,无迁移。
- **D** 门店端提现/筛选栏留白对齐:`h5-shop/styles.css``--space-page`
- **E** 休息中核销→是否开启营业:`PhoneRedeemPage`/`RedeemConfirmPage` 弹「是否开启营业?」,确认调 `PUT /shop/store/status` 开张后继续。
- **F(本次新增)** 门店套餐图片自适应完整显示:`ProductCarousel` 新增 `imageFit="adaptive"``mode="widthFix"` + 按真实比例动态算 Swiper 高度),`product-detail.css``.detail-carousel-wrap--adaptive` 覆盖固定 1:1 裁剪;仅 `store-package-detail` 使用。根因:原 `.detail-carousel-wrap` 固定 `aspect-ratio:1` + `overflow:hidden` + `aspectFill` 裁掉非正方形图。
### 发布状态
- 文档:`杜康好客-v3.4.18-门店体验与登录态优化.md`(含 A–F 范围、实现位置、验收清单)。
- git:本地 `dev_jacy` 提交 v3.4.18 各功能 + F 修复;已 `git push origin dev_jacy:main`(干净快进 `984dec7..18147e1`)。
- 生产部署:后台运行 `deploy/deploy-prod.sh --host dukang-server`/opt/dukang, main)。SSH 配置主机 `dukang-server` 即生产机;`deploy.env` 缺失,用 `--host` 覆盖。
### 注意
- 本次 git 发布前曾遇 origin/main 比本地新、working tree 异常删除 503 文件(已 `reset --hard HEAD` 还原,提交完好);最终 fetch 后确认本地领先可快进,安全发布。
- 四端 tsc 仅余项目既有环境类报错(CSS 导入/ImportMeta.env),本次改动文件无新增类型错误。
### F 项方案 A 改写(22:18
- 放弃手算高度逻辑(SelectorQuery + onLoad 测自然宽高),改用 swiper 原生 `autoHeight`:海报有多高轮播就有多高,无裁切。
- `ProductCarousel.tsx`:删 `wrapWidth`/`imgHeights`/`handleImageLoad`/`swiperStyle`adaptive 模式加 `autoHeight={isAdaptive}`image 保持 `mode="widthFix"`
- `product-detail.css``.detail-carousel-wrap--adaptive` 覆盖(删 `aspect-ratio:1`/`overflow:hidden`),并给 `.detail-carousel`/`.detail-carousel-item`/`.detail-carousel-image` 在 adaptive 下 `height:auto`
- 已提交 `cd1e9ae``git push origin dev_jacy:main`(干净快进 `18147e1..cd1e9ae`)。
- 重要:F 是原生微信小程序端改动,服务器部署只覆盖 H5-user 构建;真机小程序用户需经「微信开发者工具上传 + 微信审核」才能生效。
### E 项缺陷修复(22:46 测试不通过)
- **根因**:门店非 OPEN 时,后端 `redeem.service.ts:loadOpenStoreAccount`previewRedeem/confirmRedeem 共用)抛「门店未营业」,导致 `RedeemConfirmPage``preview` 拉取失败 → `preview` 为 null → 「确认核销」按钮 `disabled={!preview}` → 点击不了 → 开张弹窗永远不触发。手机号路径因弹窗在「发送验证码」时触发故未暴露。
- **修复**`RedeemConfirmPage` 把 preview 拉取抽成 `loadPreview()`;确认按钮改为 `disabled={loading || (!preview && !storeClosed)}`(闭店时仍可点→弹框);`openStoreAndContinue` 开张成功后 `await loadPreview()``doConfirm()`
- 弹窗文案对齐需求:「门店目前休息中无法核销,是否开启营业」(两页 modal title=门店休息中 / desc=该句;内联提示改为「门店目前休息中无法核销,开启营业后可继续」)。
- 提交 `3ac008a``git push origin dev_jacy:main`(快进 `cd1e9ae..3ac008a`),生产重新部署中(deploy-prod.sh --host dukang-server)。
- 注意:后端 `updateShopStatus` 对 CLOSED(永久关闭) 门店拒绝开张(抛「门店已永久关闭...」),属预期;需求针对 闭店/休息中(PAUSED) 场景,OK。
### E 项方案二次修正(23:00 用户纠正:前置到点击扫码)
- **用户新要求**:门店 PAUSED 时,**点击「扫码核销」按钮那一刻就弹窗**,而不是扫完码进确认页才弹。
- **改动**`apps/h5-shop/src/pages/HomePage.tsx`(门店端首页)。
- 复用 dashboard 已加载的 `status``dash?.store?.status`L384 `status = String(store?.status||'')`)。
- `handleScan()` 开头:`if (status !== 'OPEN') { setShowOpenModal(true); return; }` —— 不进入 isWechatEnv/scanQrCode 流程。
- 新增 `openStoreAndContinue()``PUT /shop/store/status {OPEN}`,成功后 `loadDashboard()` 刷新状态(开关/扫码按钮同步),用户可再次点击扫码。
- 弹窗复用全局 `.shop-redeem-modal` 样式(与确认页一致),文案「门店目前休息中无法核销,是否开启营业?」。
- `RedeemConfirmPage` 的弹窗保留为带码直入确认页的兜底。
- 提交 `c0d04ee``git push origin dev_jacy:main`(快进 `3ac008a..c0d04ee`),生产重新部署中(deploy-prod.sh --host dukang-server)。文档 E 节已同步更新方案描述。
### C 项缺陷修复(合伙人端 DISABLED 仍登录 - 微信路径)
- **现象**:手机号(短信)路径已正确拦截(login 前先 `POST /partner/auth/phone/check` 校验非 ACTIVE 即拒),但**微信 OAuth 路径**报错被吞 → 用户无任何提示。
- **根因**`apps/h5-partner/src/contexts/PartnerSessionContext.tsx` L118 `catch { stripOAuthParamsFromLocation(); }` 把微信回调里 `processPartnerWechatOAuthCallback` 抛出的「该账号已暂停使用」错误吞掉。
- **修复**`catch (e) { stripOAuthParamsFromLocation(); toastError(e); }``toastError``@/lib/toast` 引入),让微信登录被暂停时同样弹「该账号已暂停使用,请联系客服人员」。
- 提交 `5ca9aa0``git push origin dev_jacy:main`(快进 `c0d04ee..5ca9aa0`)。
- 生产部署完成(deploy-prod.sh --host dukang-server, 38s):dukang-api 200、各 h5/partner 服务 online。mini-user(h5) 健康检查 FAIL 与本次无关(非 v3.4.18 改动范围,本次改 native 小程序 + h5-shop + h5-partner)。
- **验收状态**A/B/C/D/E/F 全部提交并发布生产;C 项的短信 + 微信两条路径拦截逻辑均已落地,建议真机/微信环境复测一次确认 toast 文案展示。
### C 项双拦截增强(23:37 用户要求“两个都要拦”)
- **澄清**:city 合伙人有两个独立状态字段,别混:
- `partner_account.status`AccountStatus: ACTIVE/DISABLED= **登录账号启用态**,feature 原拦截查的就是它(子账号自身)。
- `partner_account.bindingStatus`CityPartnerStatus: ACTIVE/PAUSED= **城市合伙人绑定态**,主账号为准;子账号取父主账号的此字段。
- **需求**:子账号 `status=DISABLED` 与 主账号 `bindingStatus=PAUSED` **都要拦**
- **实现**`auth.service.ts` 新增统一闸门 `assertPartnerAccountActive(account)`(L313),同时拦上述两种;替换三处旧校验:`assertPartnerAccountByPhone`(L334) / `loginPartner`(L1086) / `loginPartnerWechat`(L1748)。
- 自身非 ACTIVE → 「该账号已暂停使用,请联系客服人员」
- 主账号 bindingStatus 非 ACTIVE → 「该合伙人合作已暂停,请联系客服人员」
- **文档**`杜康好客-v3.4.18-...md` C 节重写为双拦截;顶部范围表 + 验收清单同步。
- 提交 `48d6900``git push origin dev_jacy:main`(快进 `5ca9aa0..48d6900`),生产部署完成(task I2mWSU, 37s):commit=48d6900、api 200、各服务 online。
- **生产实测双拦截均生效**
- `status=DISABLED`(id=7 临时翻转→测试→已还原 ACTIVE):`phone/check` 返回 400「该账号已暂停使用,请联系客服人员」;还原后 201 ok:true。
- `bindingStatus=PAUSED`18049821889 等 3 个主账号):`phone/check` 返回 400「该合伙人合作已暂停,请联系客服人员」(修复前为 200 ok:true)。
- 库内 status!=ACTIVE 数量=0binding_status!=ACTIVE 数量=3(均为主账号 PAUSED)。
- **遗留未提交文件(非本次改动,需用户决策)**:
- `apps/h5-shop/src/styles.css``shop-records-status-chips``padding:0``padding-bottom:10px`D 项后续微调,未提交)。
- `apps/mini-user/src/styles/product-detail.css`:工作区版本把方案A`aspect-ratio:auto`/`height:auto`)改回固定 4:3`aspect-ratio:1.33`/`height:100%`),与已上线方案A矛盾,疑似另一轮实验性改动。已与用户确认前**暂不提交**。
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"antd": "^5.22.0",
+18
View File
@@ -86,3 +86,21 @@ body,
.admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap;
}
.admin-package-audit-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.admin-package-audit-col {
min-width: 0;
}
.admin-package-audit-text {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
max-width: none;
}
@@ -1,12 +1,20 @@
import { useEffect, useState } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import type { StorePackageItemDto } from '@dukang/shared-types';
import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request } from '../lib/api';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto;
export type AdminStorePackagesHandle = {
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
};
function emptyRow(index = 0): PackageRow {
return {
name: '',
@@ -20,16 +28,31 @@ function emptyRow(index = 0): PackageRow {
};
}
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
function AdminStorePackagesSection({ storeId }, ref) {
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
const itemsRef = useRef(items);
const loadingRef = useRef(loading);
const navigate = useNavigate();
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
loadingRef.current = loading;
}, [loading]);
useEffect(() => {
setLoading(true);
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
setPendingRequest(null);
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
.then((data) => {
setPendingRequest(data.pendingRequest ?? null);
setItems(
data.live?.length
? data.live.map((p, i) => {
@@ -42,13 +65,48 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
sortOrder: i,
};
})
: [emptyRow()],
: [],
);
})
.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)));
}
@@ -86,12 +144,14 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
}
run();
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
async function save() {
const filled = items
async function save(opts?: { quiet?: boolean }) {
const currentItems = itemsRef.current;
const filled = currentItems
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
@@ -105,26 +165,32 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
sortOrder: index,
};
})
.filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0);
// 允许整店无套餐:忽略空白占位行(默认 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} 条套餐名称不能为空`);
return;
throw new Error('套餐校验失败');
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
return;
throw new Error('套餐校验失败');
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
throw new Error('套餐校验失败');
}
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`);
return;
throw new Error('套餐校验失败');
}
}
@@ -136,7 +202,7 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
message.success('套餐已保存并生效');
if (!opts?.quiet) message.success('套餐已保存并生效');
setItems(
data.live?.length
? data.live.map((p, i) => {
@@ -149,15 +215,24 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
sortOrder: i,
};
})
: [emptyRow()],
: [],
);
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
throw e;
} finally {
setSaving(false);
}
}
useImperativeHandle(ref, () => ({
saveIfLoaded: async (opts) => {
if (loadingRef.current) return { skipped: true };
await save(opts);
return { skipped: false };
},
}));
if (loading) {
return <Typography.Text type="secondary"></Typography.Text>;
}
@@ -166,17 +241,20 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
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()}>
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button>
</Form>
);
}
return ( <Form layout="vertical" requiredMark={false}>
return (
<Form layout="vertical" requiredMark={false}>
{pendingReminder}
<Alert
type="info"
showIcon
@@ -213,7 +291,8 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
<Button type="link" danger onClick={() => removeAt(index)}>
</Button>
) : null} </Space>
) : null}
</Space>
{!isCollapsed ? (
<>
@@ -247,7 +326,8 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
</Form.Item>
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
<Input
<Input.TextArea
rows={2}
placeholder="节假日除外"
value={item.usableTime || ''}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
@@ -266,7 +346,9 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
/>
</Form.Item>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}> <Input
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
<Input.TextArea
rows={2}
placeholder="不可叠加"
value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
@@ -285,12 +367,15 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
) : null}
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
</Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save()}>
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button>
</Form>
);
}
},
);
export default AdminStorePackagesSection;
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Image, Space, Typography, Upload, message } from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
@@ -13,12 +13,18 @@ type Props = {
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[]。
@@ -31,6 +37,7 @@ export default function MultiImageUpload({
maxCount,
tip,
accept = 'image/*',
buttonText = '批量上传图片',
}: Props) {
const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
@@ -143,12 +150,36 @@ export default function MultiImageUpload({
<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
@@ -176,7 +207,7 @@ export default function MultiImageUpload({
disabled={uploading || !canAdd}
>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
{canAdd ? '批量上传图片' : '已达上限'}
{canAdd ? buttonText : '已达上限'}
</Button>
</Upload>
</Space>
+63 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
@@ -15,6 +15,14 @@ type OssUploadProps = {
placeholder?: string;
};
function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
export default function OssUpload({
value,
onChange,
@@ -25,6 +33,7 @@ export default function OssUpload({
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
@@ -47,6 +56,57 @@ export default function OssUpload({
}
};
const filePreview =
value && mediaType === 'FILE' ? (
isImageUrl(value) ? (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : isPdfUrl(value) ? (
<Space direction="vertical" size={8}>
<div
style={{
width: 120,
height: 120,
borderRadius: 4,
border: '1px solid #f0f0f0',
background: '#fafafa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 36 }} />
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF </span>
</div>
<Space wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
</Button>
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
</Button>
</Space>
<Modal
title="签约合同预览"
open={pdfPreviewOpen}
onCancel={() => setPdfPreviewOpen(false)}
footer={null}
width="90vw"
styles={{ body: { height: '75vh', padding: 0 } }}
destroyOnClose
>
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
</Modal>
</Space>
) : (
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
</Button>
)
) : null;
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && (
@@ -55,6 +115,7 @@ export default function OssUpload({
{value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)}
{filePreview}
<Space wrap>
<Upload
accept={resolvedAccept}
+50 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd';
import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
import type { MenuProps } from 'antd';
import {
RobotOutlined,
@@ -23,6 +23,7 @@ import {
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
const { Header, Sider, Content } = Layout;
@@ -243,6 +244,31 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
.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';
export default function AdminLayout() {
@@ -250,11 +276,28 @@ export default function AdminLayout() {
const location = useLocation();
const contentRef = useRef<HTMLDivElement>(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(() => {
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(() => {
contentRef.current?.scrollTo({ top: 0, left: 0 });
}, [location.pathname]);
@@ -273,10 +316,12 @@ export default function AdminLayout() {
: location.pathname;
const menuItems = useMemo(() => {
if (!profile) return MENU_ITEMS;
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
}, [profile]);
const base =
!profile || profile.adminRole === 'SUPER_ADMIN'
? MENU_ITEMS
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
return attachPackageAuditBadge(base, packagePendingCount);
}, [profile, packagePendingCount]);
return (
<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));
}
+6
View File
@@ -168,6 +168,12 @@ export type AdminUserRow = {
createdAt: string;
orderCount: number;
isTest?: boolean;
/** 好客权益·累计获得(含已使用,不含退款作废) */
benefitTotalAmount?: number;
/** 好客权益·已使用(已核销) */
benefitUsedAmount?: number;
/** 好客权益·剩余未使用 */
benefitBalance?: number;
};
export type AdminOrderItem = {
+21 -2
View File
@@ -140,10 +140,29 @@ export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—';
}
/** 手机号脱敏展示:138****5678;空值返回 — */
/** 联系电话脱敏:手机 138****5678;座机 0379-****888;空值返回 — */
export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim();
const raw = String(phone ?? '').trim().replace(/\s+/g, '');
if (!raw) return '—';
if (/^1[3-9]\d{9}$/.test(raw)) {
const digits = raw.replace(/\D/g, '');
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
const extMatch = raw.match(/-(\d{1,6})$/);
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
const ext = hasExt ? extMatch![1] : '';
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
const digits = main.replace(/\D/g, '');
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
const area = digits.slice(0, areaLen);
const local = digits.slice(areaLen);
const keepTail = Math.min(4, Math.max(2, local.length - 4));
const maskedLocal =
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
const joiner = main.includes('-') ? '-' : '';
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
}
const digits = raw.replace(/\D/g, '');
if (digits.length >= 11) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
+9 -1
View File
@@ -1,3 +1,5 @@
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
export type StoreCreateForm = {
partnerAccountId: string;
cityId: string;
@@ -15,6 +17,8 @@ export type StoreCreateForm = {
longitude?: number | null;
intro?: string;
benefitUsageRule?: string;
/** 对外联系电话(店长);可与登录号不同,支持座机 */
contactPhone?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
@@ -22,7 +26,8 @@ export type StoreCreateForm = {
avgPrice?: number | null;
coverUrl?: string;
envPhotoUrls?: string[];
contractUrl?: string;
/** 签约合同,支持多张照片 / PDF */
contractUrls?: string[];
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
@@ -58,6 +63,7 @@ export function validateStoreCreateStep1(
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'contactPhone'
>,
): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人';
@@ -67,6 +73,8 @@ export function validateStoreCreateStep1(
if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
const contactPhone = form.contactPhone?.trim();
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
if (!form.address?.trim()) return '请填写详细地址';
const openTime = String(form.openTime || '').trim();
@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Badge,
Button,
Drawer,
Image,
Input,
Modal,
Space,
@@ -13,19 +16,97 @@ import {
import type { ColumnsType } from 'antd/es/table';
import type {
StorePackageAuditDetailDto,
StorePackageAuditSummaryDto,
StorePackageChangeRequestDto,
StorePackageChangeStatus,
StorePackageItemDto,
StorePackageViewDto,
} 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 { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核',
APPROVED: '已通过',
REJECTED: '已驳回',
};
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
const name = String(pkg.name ?? '').trim();
return name ? `name:${name}` : `idx:${index}`;
}
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
return normalizeStorePackageImageUrls(pkg).join('|');
}
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
/** 文本逐字差异: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[]) {
const liveMap = new Map(live.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';
live?: StorePackageViewDto;
proposed?: StorePackageItemDto;
changes?: FieldChange[];
}> = [];
for (const key of keys) {
@@ -49,8 +131,15 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
l.price !== p.price ||
l.dishes !== p.dishes ||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
imageSignature(l) !== imageSignature(p);
rows.push({
key,
change: changed ? 'changed' : 'unchanged',
live: l,
proposed: p,
changes: changed ? fieldChanges(l, p) : undefined,
});
}
}
return rows;
@@ -63,12 +152,158 @@ const CHANGE_LABELS = {
unchanged: { text: '未变', color: 'default' },
} as const;
/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
const segs = diffText(oldText, newText);
return (
<div style={{ marginTop: 2 }}>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'insert')
.map((s, idx) =>
s.type === 'delete' ? (
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'delete')
.map((s, idx) =>
s.type === 'insert' ? (
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
</div>
);
}
function PackageDetailCard({
title,
pkg,
change,
changes,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
change?: keyof typeof CHANGE_LABELS;
changes?: FieldChange[];
}) {
const images = normalizeStorePackageImageUrls(pkg);
const meta = change ? CHANGE_LABELS[change] : null;
return (
<div
style={{
marginBottom: 12,
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
<Space style={{ marginBottom: 8 }} wrap>
{title ? (
<Typography.Text type="secondary">{title}</Typography.Text>
) : null}
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
</Space>
<div style={{ marginBottom: 8 }}>
<strong>{pkg.name}</strong>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
</div>
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 4 }}
>
{pkg.usableTime}
</Typography.Paragraph>
) : null}
{pkg.otherNotes ? (
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 8 }}
>
{pkg.otherNotes}
</Typography.Paragraph>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image
key={url}
src={url}
width={72}
height={72}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
{changes && changes.length ? (
<div
style={{
marginTop: 8,
padding: 8,
background: '#fff7e6',
border: '1px solid #ffe7ba',
borderRadius: 6,
}}
>
<Typography.Text strong style={{ fontSize: 12 }}>
</Typography.Text>
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{changes.map((c) => (
<li key={c.label} style={{ marginBottom: 6 }}>
<Typography.Text type="secondary">{c.label}</Typography.Text>
{c.kind === 'text' ? (
<TextDiff oldText={c.old} newText={c.now} />
) : (
<>
<Typography.Text delete type="secondary">
{c.old}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>{c.now}</Typography.Text>
</>
)}
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
export default function StorePackageAuditsPage() {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('PENDING');
const [pendingCount, setPendingCount] = useState(0);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
@@ -84,12 +319,14 @@ export default function StorePackageAuditsPage() {
pageSize: '20',
});
if (nextStatus) qs.set('status', nextStatus);
const data = await request<Paginated<StorePackageChangeRequestDto>>(
`/admin/store-package-audits?${qs}`,
);
const [data, summary] = await Promise.all([
request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`),
request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'),
]);
setItems(data.items);
setTotal(data.total);
setPage(data.page);
setPendingCount(summary.pendingCount ?? 0);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
@@ -101,6 +338,14 @@ export default function StorePackageAuditsPage() {
void reload(1, 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) {
setDetailOpen(true);
setDetailLoading(true);
@@ -126,6 +371,7 @@ export default function StorePackageAuditsPage() {
});
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
setDetailOpen(false);
notifyPackageAuditChanged();
void reload(page, status);
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
@@ -133,54 +379,12 @@ export default function StorePackageAuditsPage() {
}
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
{
title: '变更',
dataIndex: 'change',
width: 72,
render: (v: keyof typeof CHANGE_LABELS) => {
const meta = CHANGE_LABELS[v];
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{
title: '当前线上',
render: (_, row) =>
row.live ? (
<div>
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
{row.live.usableTime ? (
<div><Typography.Text type="secondary">{row.live.usableTime}</Typography.Text></div>
) : null}
{row.live.otherNotes ? (
<div><Typography.Text type="secondary">{row.live.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
},
{
title: '申请变更',
render: (_, row) =>
row.proposed ? (
<div>
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
{row.proposed.usableTime ? (
<div><Typography.Text type="secondary">{row.proposed.usableTime}</Typography.Text></div>
) : null}
{row.proposed.otherNotes ? (
<div><Typography.Text type="secondary">{row.proposed.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
},
];
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
const addedCount = diffRows.filter((r) => r.change === 'added').length;
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
const columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
@@ -188,7 +392,7 @@ export default function StorePackageAuditsPage() {
title: '状态',
dataIndex: '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 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
{s === 'PENDING' ? (
<Badge count={pendingCount} size="small" offset={[8, -2]}>
{HQ_PACKAGE_STATUS_LABELS.PENDING}
</Badge>
) : s ? (
HQ_PACKAGE_STATUS_LABELS[s]
) : (
'全部'
)}
</Button>
))}
</Space>
@@ -257,7 +469,7 @@ export default function StorePackageAuditsPage() {
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
width={720}
width={880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
extra={
@@ -282,8 +494,8 @@ export default function StorePackageAuditsPage() {
<Typography.Text type="secondary"></Typography.Text>
) : detail ? (
<>
<Space style={{ marginBottom: 16 }}>
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
</Typography.Text>
@@ -291,16 +503,54 @@ export default function StorePackageAuditsPage() {
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<Typography.Paragraph type="secondary">
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0}
</Typography.Paragraph>
<Table
size="small"
rowKey="key"
columns={diffColumns}
dataSource={diffRows}
pagination={false}
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
<Typography.Text type="secondary">
线 {detail.livePackages?.length ?? 0} · {detail.packages?.length ?? 0}
</Typography.Text>
<Space wrap>
<Tag color="green"> {addedCount}</Tag>
<Tag color="red"> {removedCount}</Tag>
<Tag color="orange"> {changedCount}</Tag>
{unchangedCount ? <Tag> {unchangedCount}</Tag> : null}
</Space>
</Space>
<div className="admin-package-audit-cols">
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
线
</Typography.Title>
{(detail.livePackages ?? []).length ? (
(detail.livePackages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`live-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary">线</Typography.Text>
)}
</div>
<div className="admin-package-audit-col">
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{(detail.packages ?? []).length ? (
(detail.packages ?? []).map((pkg, index) => (
<PackageDetailCard
key={`pending-${packageKey(pkg, index)}`}
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
/>
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
</div>
</>
) : null}
</Drawer>
+173 -35
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
@@ -24,6 +24,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd/es/form';
import { EnvironmentOutlined } from '@ant-design/icons';
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
import { request, type Paginated } from '../lib/api';
import {
ADMIN_OPTIONS_PAGE_SIZE,
@@ -42,7 +43,9 @@ import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import TencentLocPickerModal from '../components/TencentLocPickerModal';
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
import AdminStorePackagesSection, {
type AdminStorePackagesHandle,
} from '../components/AdminStorePackagesSection';
const CREATE_STEPS = [
{ title: '基本信息' },
@@ -114,7 +117,7 @@ function StoreAuditMediaEditor() {
type="info"
showIcon
style={{ marginBottom: 16 }}
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。"
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照、签约合同均最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
/>
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
@@ -131,8 +134,20 @@ function StoreAuditMediaEditor() {
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
<Form.Item name="contractUrl" label="签约合同" style={{ marginTop: 16 }}>
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
<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>
);
@@ -175,6 +190,8 @@ type StoreRow = {
createdAt: string;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
pendingPackageAuditId?: string | null;
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
@@ -251,6 +268,7 @@ export default function StoresPage() {
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const packagesRef = useRef<AdminStorePackagesHandle>(null);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [auditing, setAuditing] = useState(false);
@@ -365,12 +383,18 @@ export default function StoresPage() {
account?.phone ||
(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);
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
editForm.setFieldsValue({
name: d.name,
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
phone: storePhone || loginPhone,
// 登录手机号(老板);与 StoreAccount 同步
phone: loginPhone || storePhone,
contactPhone,
intro: d.intro,
benefitUsageRule:
d.benefitUsageRule != null &&
@@ -384,9 +408,9 @@ export default function StoresPage() {
const urls = envs.map((item) => item.url).filter(Boolean);
return urls;
})(),
contractUrl: (() => {
contractUrls: (() => {
const { contracts } = collectMediaUrls(d);
return contracts[0]?.url || '';
return contracts.map((item) => item.url).filter(Boolean);
})(),
province: d.province,
city: d.cityName,
@@ -428,11 +452,14 @@ export default function StoresPage() {
const payload = {
name: v.name,
phone: v.phone,
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)
: [],
contractUrl: v.contractUrl ?? '',
contractUrls: Array.isArray(v.contractUrls)
? v.contractUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
intro: v.intro,
benefitUsageRule:
typeof v.benefitUsageRule === 'string' &&
@@ -465,7 +492,10 @@ export default function StoresPage() {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('门店信息已保存');
const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true });
message.success(
packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存',
);
setDetail(updated);
setPhoneMismatch(null);
void reload();
@@ -592,6 +622,7 @@ export default function StoresPage() {
}
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', {
method: 'POST',
body: JSON.stringify({
@@ -602,6 +633,7 @@ export default function StoresPage() {
city: values.city,
name: values.name.trim(),
phone: values.phone.trim(),
contactPhone: String(values.contactPhone || values.phone || '').trim(),
district: values.district.trim(),
address: values.address.trim(),
...(values.latitude != null &&
@@ -625,7 +657,7 @@ export default function StoresPage() {
: {}),
coverUrl: values.coverUrl?.trim() || undefined,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: values.contractUrl?.trim() || undefined,
contractUrls: contractUrls.length ? contractUrls : undefined,
bankAccountName: values.bankAccountName.trim(),
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(),
@@ -655,19 +687,37 @@ export default function StoresPage() {
title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
},
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{
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: '分类',
width: 100,
ellipsis: true,
render: (_, row) => row.category?.name || '—',
},
{ title: '城市', dataIndex: 'cityName', width: 80 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
{ title: '登录号', dataIndex: 'phone', width: 120 },
{
title: '联系电话',
dataIndex: 'contactPhone',
width: 120,
render: (v: string | null | undefined, row) => v || row.phone,
},
{
title: '营业状态', dataIndex: 'status', width: 90,
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
@@ -678,10 +728,10 @@ export default function StoresPage() {
const status = s || 'APPROVED';
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
return (
<Space direction="vertical" size={0}>
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{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}
</Typography.Text>
) : null}
@@ -693,8 +743,16 @@ export default function StoresPage() {
title: '开城合伙人',
dataIndex: 'partner',
width: 140,
render: (partner: StoreRow['partner']) =>
partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—',
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: '可见',
@@ -705,14 +763,34 @@ export default function StoresPage() {
},
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 140,
title: '操作',
width: 220,
fixed: 'right',
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={() => 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>
),
},
@@ -780,8 +858,24 @@ export default function StoresPage() {
</Button>
</Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Table
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)}
extra={detail && (
<Space wrap>
@@ -878,18 +972,34 @@ export default function StoresPage() {
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={`登录账号手机号仍${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录`}
message={`主账号登录号${phoneMismatch},与门店登录字段不一致。保存「登录手机号」将同步到门店端登录账号`}
/>
) : null}
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机号"
label="登录手机号(老板)"
rules={[{ required: true }]}
extra="门店端短信登录使用此号码;修改后需用新号重新登录"
extra="门店端主账号短信登录;修改后需用新号重新登录"
>
<Input />
</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
name="categoryParentId"
label="门店分类(大类)"
@@ -1066,7 +1176,8 @@ export default function StoresPage() {
{
key: 'packages',
label: '套餐',
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
forceRender: true,
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
},
]}
/>
@@ -1164,8 +1275,24 @@ export default function StoresPage() {
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
<Input placeholder="请输入门店名称" />
</Form.Item>
<Form.Item name="phone" label="门店手机号(登录账号" rules={[{ required: true, message: '请填写门店手机号' }]}>
<Input placeholder="11位手机号" />
<Form.Item name="phone" label="登录手机号(老板" rules={[{ required: true, message: '请填写登录手机号' }]}>
<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" />
@@ -1266,8 +1393,19 @@ export default function StoresPage() {
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
<Form.Item name="contractUrl" label="签约合同">
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
<Form.Item
name="contractUrls"
label="签约合同"
extra="选填;支持多张合同照片或 PDF,最多 20 个"
>
<MultiImageUpload
bizType="STORE_CONTRACT"
mediaType="FILE"
accept="image/*,.pdf"
maxCount={20}
buttonText="批量上传合同"
tip="合同支持一次选择多张照片批量上传,最多 20 个"
/>
</Form.Item>
</div>
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
+53 -1
View File
@@ -30,6 +30,13 @@ type UserOrderRow = {
createdAt: string;
};
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
function fmtBenefit(v: number | null | undefined) {
const n = Number(v ?? 0);
if (!Number.isFinite(n) || n <= 0) return '—';
return `¥${n.toFixed(2)}`;
}
type UserBehaviorLog = {
id: string;
eventName: string;
@@ -315,6 +322,39 @@ export default function UsersPage() {
render: (v) => (v ? <Tag color="blue"></Tag> : '—'),
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{
title: '剩余权益',
dataIndex: 'benefitBalance',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0),
render: (v: number | undefined) =>
Number(v ?? 0) > 0 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(v)}
</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '已用权益',
dataIndex: 'benefitUsedAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
render: (v: number | undefined) => (
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
),
},
{
title: '累计权益',
dataIndex: 'benefitTotalAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0),
render: (v: number | undefined) => fmtBenefit(v),
},
{
title: '注册时间',
dataIndex: 'createdAt',
@@ -389,7 +429,7 @@ export default function UsersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
scroll={{ x: 1830 }}
rowSelection={canDeleteUsers ? {
selectedRowKeys,
preserveSelectedRowKeys: true,
@@ -461,6 +501,18 @@ export default function UsersPage() {
</Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
<Descriptions.Item label="好客权益">
<Space size={16} wrap>
<span>
{' '}
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(detail.benefitBalance)}
</Typography.Text>
</span>
<span> {fmtBenefit(detail.benefitUsedAmount)}</span>
<span> {fmtBenefit(detail.benefitTotalAmount)}</span>
</Space>
</Descriptions.Item>
<Descriptions.Item label="注册时间">
{new Date(detail.createdAt).toLocaleString('zh-CN')}
</Descriptions.Item>
+39 -11
View File
@@ -51,13 +51,35 @@ type BillItem = {
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
NO_PAYMENT_NEEDED: '无需打款',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
NO_PAYMENT_NEEDED: 'default',
};
function isZeroPayable(amount: number | string | null | undefined) {
return Number(amount ?? 0) === 0;
}
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
function displayWineryStatus(status: string, wineryAmount: number | string) {
if (isZeroPayable(wineryAmount)) {
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
}
return {
key: status,
label: STATUS_LABELS[status] || status,
color: STATUS_COLORS[status] || 'default',
};
}
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
}
const DELIVERY_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
@@ -212,7 +234,7 @@ export default function WineryBillsPage() {
render: (v) => `${Math.round(Number(v) * 100)}%`,
},
{
title: '酒厂应付',
title: '应付',
dataIndex: 'wineryAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
@@ -220,8 +242,11 @@ export default function WineryBillsPage() {
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
width: 100,
render: (s, row) => {
const d = displayWineryStatus(s, row.wineryAmount);
return <Tag color={d.color}>{d.label}</Tag>;
},
},
{
title: '操作',
@@ -232,7 +257,7 @@ export default function WineryBillsPage() {
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
{canConfirmWineryPay(row) && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
</Button>
@@ -258,7 +283,8 @@ export default function WineryBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+3 8:00 3 / × {ratePct}%绿
T+3 8:00 3 / × {ratePct}%绿 0
</Typography.Text>
</Space>
{canEditWineryBank ? (
@@ -273,7 +299,7 @@ export default function WineryBillsPage() {
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<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>
</Card>
)}
@@ -296,7 +322,7 @@ export default function WineryBillsPage() {
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
style={{ width: 130 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
@@ -347,7 +373,7 @@ export default function WineryBillsPage() {
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
}}
scroll={{ x: 1100 }}
pagination={{
@@ -368,8 +394,10 @@ export default function WineryBillsPage() {
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
@@ -395,7 +423,7 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '酒厂应付',
title: '应付',
dataIndex: 'wineryAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
+4 -1
View File
@@ -11,7 +11,10 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
"strict": true,
"paths": {
"@dukang/domain": ["../../packages/domain/src/index.ts"]
}
},
"include": ["src"]
}
+7
View File
@@ -1,11 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
},
},
server: {
host: true,
port: 5175,
proxy: { '/api': apiTarget },
},
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@dukang/client-logging": "workspace:*",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
@@ -13,12 +13,20 @@ type Props = {
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);
}
@@ -32,6 +40,8 @@ export default function MultiOssUploadField({
maxCount = 20,
disabled,
label,
accept = 'image/*',
unit = '张',
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const pickingRef = useRef(false);
@@ -39,9 +49,19 @@ export default function MultiOssUploadField({
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(() => {});
@@ -53,11 +73,11 @@ export default function MultiOssUploadField({
}
async function uploadFiles(files: File[]) {
const current = normalizeUrls(value);
const current = urlsRef.current;
const room = Math.max(0, maxCount - current.length);
const picked = files.slice(0, room);
if (!picked.length) {
showUploadError(`最多 ${maxCount} `);
showUploadError(`最多 ${maxCount} ${unit}`);
return;
}
setUploading(true);
@@ -70,8 +90,10 @@ export default function MultiOssUploadField({
appended.push(result.url);
}
if (appended.length) {
onChange?.([...current, ...appended]);
toastSuccess(`已上传 ${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 : '上传失败');
@@ -114,7 +136,7 @@ export default function MultiOssUploadField({
<input
ref={inputRef}
type="file"
accept="image/*"
accept={accept}
multiple
className="partner-oss-upload-input"
disabled={disabled || uploading || remaining <= 0}
@@ -128,11 +150,37 @@ export default function MultiOssUploadField({
<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"
@@ -174,7 +222,7 @@ export default function MultiOssUploadField({
{uploading
? '上传中…'
: remaining <= 0
? `已达上限 ${maxCount}`
? `已达上限 ${maxCount}${unit}`
: label ?? `批量上传(${urls.length}/${maxCount}`}
</span>
</button>
@@ -119,16 +119,14 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
<div className="partner-field">
<label>使</label>
<div className="partner-field-input">
<span className="material-symbols-outlined">schedule</span>
<input
<textarea
rows={2}
placeholder="节假日除外"
value={item.usableTime || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</div>
</div>
<div className="partner-field">
<label> {STORE_PACKAGE_IMAGE_MAX_COUNT} </label>
@@ -154,16 +152,14 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">info</span>
<input
<textarea
rows={2}
placeholder="不可叠加"
value={item.otherNotes || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</div>
</div>
</>
) : null}
</section>
@@ -20,6 +20,7 @@ import {
} from '../lib/api';
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
import { toastError } from '../lib/toast';
export type PartnerAccount = PartnerMe & {
staffRole?: PartnerStaffRole;
@@ -115,7 +116,10 @@ export function PartnerSessionProvider({ children }: { children: ReactNode }) {
}
stripOAuthParamsFromLocation();
}
} catch {
} catch (e) {
// 微信 OAuth 回跳后后端可能因账号暂停(DISABLED)等拒绝登录,
// 必须把错误显式提示出来,否则用户无任何反馈(与短信路径一致)。
toastError(e instanceof Error ? e.message : '微信登录失败');
stripOAuthParamsFromLocation();
}
}
+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 = {
regionCodes: string[];
cityId: string;
@@ -5,7 +8,10 @@ export type StoreDraftForm = {
city: string;
district: string;
name: string;
/** 门店登录手机号(老板主账号) */
phone: string;
/** 对外联系电话(店长);可与登录号不同 */
contactPhone: string;
address: string;
/** 门店坐标(定位或地理编码) */
latitude: string;
@@ -25,7 +31,8 @@ export type StoreDraftForm = {
benefitUsageRule: string;
coverUrl: string;
envPhotoUrls: string[];
contractUrl: string;
/** 签约合同,支持多张照片 / PDF */
contractUrls: string[];
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
@@ -44,13 +51,12 @@ export function storeDraftKey(accountId?: string): string {
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
}
import { getDefaultPartnerRegionForm } from './china-region';
export const defaultStoreForm = (): StoreDraftForm => ({
...getDefaultPartnerRegionForm(),
cityId: '',
name: '',
phone: '',
contactPhone: '',
address: '',
latitude: '',
longitude: '',
@@ -66,7 +72,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
benefitUsageRule: '',
coverUrl: '',
envPhotoUrls: ['', '', ''],
contractUrl: '',
contractUrls: [],
bankAccountName: '',
bankAccountNo: '',
bankBranch: '',
@@ -84,6 +90,9 @@ function timeToMinutes(hhmm: string): number {
export const MIN_ENV_PHOTO_COUNT = 3;
/** 签约合同最多可上传的照片 / PDF 数量 */
export const MAX_CONTRACT_COUNT = 20;
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
while (arr.length < minLen) arr.push('');
@@ -102,6 +111,9 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
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,
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
openTime: String(raw.openTime ?? base.openTime),
@@ -111,6 +123,22 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
avgPrice: String(raw.avgPrice ?? base.avgPrice),
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)
? raw.packages.map((p, i) => ({
name: String((p as { name?: string }).name ?? ''),
@@ -217,12 +245,13 @@ export function validateStoreStep1(
}
export function validateStoreStep2(
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>,
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrls'>,
): string | null {
if (!form.coverUrl.trim()) return '请上传门头照';
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
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;
}
@@ -236,14 +265,16 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
export function validateStoreStep3(
form: Pick<
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'contactPhone'
>,
): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.phone.trim()) return '请填写门店登录手机号';
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;
}
+18 -2
View File
@@ -2,7 +2,7 @@ export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | stri
export function storeAuditLabel(auditStatus?: string | null): string {
const s = String(auditStatus || 'APPROVED').toUpperCase();
if (s === 'PENDING') return '待总部审核';
if (s === 'PENDING') return '待审核';
if (s === 'REJECTED') return '审核驳回';
if (s === 'APPROVED') return '审核通过';
return auditStatus || '—';
@@ -21,10 +21,26 @@ export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '临时闭店';
if (s === 'CLOSED') return '永久闭';
if (s === 'CLOSED') return '永久闭';
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 {
const s = String(status).toUpperCase();
if (s === 'OPEN') return 'partner-status-pill--open';
+12 -4
View File
@@ -71,16 +71,24 @@ function formatPartnerError(e: unknown): string {
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
return '未找到合伙人账号';
}
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
return '合伙人账号已暂停,无法登录';
if (
text.includes('合伙人账号已停用') ||
text.includes('账号已停用') ||
text.includes('暂停使用')
) {
return '该账号已暂停使用,请联系客服人员';
}
return text;
}
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
return '合伙人账号已暂停,无法登录';
if (
text.includes('合伙人账号已停用') ||
text.includes('账号已停用') ||
text.includes('暂停使用')
) {
return '该账号已暂停使用,请联系客服人员';
}
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
+159 -16
View File
@@ -8,6 +8,7 @@ import OssUploadField from '../components/OssUploadField';
import MultiOssUploadField from '../components/MultiOssUploadField';
import { request } from '../lib/api';
import { fetchClientConfig } from '../lib/wechat-auth';
import { toastError, toastSuccess } from '../lib/toast';
import { resolveRegionBinding } from '../lib/china-region';
@@ -42,12 +43,17 @@ import {
validateStoreStep3,
MIN_ENV_PHOTO_COUNT,
MAX_CONTRACT_COUNT,
} from '../lib/storeDraft';
import StorePackagesForm from '../components/StorePackagesForm';
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
const DEFAULT_PARTNER_ONBOARD_CS_HINT =
'使用问题、提现问题等随时可联系【杜康好客】客服';
type StoreCategoryNode = {
id: string;
name: string;
@@ -56,6 +62,7 @@ type StoreCategoryNode = {
type FieldErrors = {
phone?: string;
contactPhone?: string;
};
@@ -97,6 +104,10 @@ export default function StoreCreatePage() {
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
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[]>([]);
@@ -148,6 +159,29 @@ export default function StoreCreatePage() {
});
}, [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(() => {
void fetchPartnerCities()
@@ -237,9 +271,17 @@ export default function StoreCreatePage() {
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 } : {}),
}));
}
@@ -315,7 +357,9 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
setFieldErrors({ phone: msg });
setFieldErrors(
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
);
reportStepError(msg);
return;
}
@@ -331,12 +375,23 @@ export default function StoreCreatePage() {
async function submit(skipPackages = false) {
const qr = (csQrUrl ?? '').trim();
if (!qr) {
reportFormError('客服二维码暂未配置,请联系总部');
return;
}
if (!csAdded) {
reportFormError('请先勾选「我已添加【杜康好客】客服」');
return;
}
const msg = validateStoreStep3(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
setFieldErrors({ phone: msg });
setFieldErrors(
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
);
return;
}
reportFormError(msg);
@@ -423,7 +478,11 @@ export default function StoreCreatePage() {
const envPhotoUrls = Array.from(
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', {
@@ -441,6 +500,8 @@ export default function StoreCreatePage() {
phone: form.phone.trim(),
contactPhone: form.contactPhone.trim() || form.phone.trim(),
district: form.district.trim(),
address: form.address.trim(),
@@ -471,7 +532,7 @@ export default function StoreCreatePage() {
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: form.contractUrl.trim() || undefined,
contractUrls: contractUrls.length ? contractUrls : undefined,
bankAccountName: form.bankAccountName.trim(),
@@ -522,6 +583,7 @@ export default function StoreCreatePage() {
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
const canSubmitOnboard = !!csQrUrl?.trim() && csAdded && !submitting && !csConfigLoading;
const nextDisabled = submitting;
@@ -957,9 +1019,11 @@ export default function StoreCreatePage() {
<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"
@@ -967,11 +1031,15 @@ export default function StoreCreatePage() {
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}`}
/>
@@ -1089,7 +1157,43 @@ export default function StoreCreatePage() {
<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>
@@ -1102,6 +1206,37 @@ export default function StoreCreatePage() {
{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">
<div className="partner-section-title">
<div className="partner-section-bar" />
@@ -1147,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 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 ? '提交中…' : '提交'}
</button>
</>
+27 -6
View File
@@ -43,7 +43,8 @@ export default function StoreDetailPage() {
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({
name: '',
phone: '',
loginPhone: '',
contactPhone: '',
address: '',
intro: '',
benefitUsageRule: '',
@@ -64,7 +65,8 @@ export default function StoreDetailPage() {
setStore(data);
setForm({
name: String(data.name || ''),
phone: String(data.phone || ''),
loginPhone: String(data.phone || ''),
contactPhone: String(data.contactPhone || data.phone || ''),
address: String(data.address || ''),
intro: String(data.intro || ''),
benefitUsageRule: (() => {
@@ -164,7 +166,7 @@ export default function StoreDetailPage() {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim(),
contactPhone: form.contactPhone.trim(),
address: form.address.trim(),
intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
@@ -283,7 +285,7 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<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>
</div>
@@ -346,12 +348,31 @@ export default function StoreDetailPage() {
<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 }} />
</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">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<span className="material-symbols-outlined">phone_in_talk</span>
<input
disabled={readOnly}
type="tel"
placeholder="手机号或座机,如 0379-8888888"
value={form.contactPhone}
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
/>
</div>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
使
</p>
</div>
<div className="partner-field">
<label></label>
+119 -67
View File
@@ -8,9 +8,7 @@ import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAcce
import {
canPartnerOpenStore,
storeAuditLabel,
storeAuditPillClass,
storeStatusLabel,
storeStatusPillClass,
storeListBadge,
type StoreStatusValue,
} from '../lib/storeStatus';
import { usePartnerPageView } from '../lib/usePageView';
@@ -20,10 +18,10 @@ type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'OPEN', label: '营业中' },
{ key: 'PAUSED', label: '时闭店' },
{ key: 'PAUSED', label: '时闭店' },
{ key: 'PENDING_AUDIT', label: '待审核' },
{ key: 'REJECTED', label: '已驳回' },
{ key: 'CLOSED', label: '关闭' },
{ key: 'CLOSED', label: '永久闭店' },
];
export default function StoreListPage() {
@@ -46,7 +44,10 @@ export default function StoreListPage() {
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
if (!isLoggedIn()) {
navigate('/login');
return;
}
void loadStores();
}, [navigate, loadStores]);
@@ -54,7 +55,9 @@ export default function StoreListPage() {
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => {
const filtered = useMemo(
() =>
stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
const status = String(s.status).toUpperCase();
@@ -63,11 +66,17 @@ export default function StoreListPage() {
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
else if (filter !== 'ALL') matchStatus = status === filter;
return matchQ && matchStatus;
}), [stores, q, filter]);
}),
[stores, q, filter],
);
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
setError(
auditStatus === 'REJECTED'
? '门店审核未通过,请查看驳回原因并修改后重新提交'
: '门店尚在总部审核中,通过后方可开门',
);
return;
}
if (next === 'CLOSED') {
@@ -82,7 +91,8 @@ export default function StoreListPage() {
body: JSON.stringify({ status: next }),
});
await loadStores();
if (next === 'OPEN') toastSuccess('开店成功');
if (next === 'OPEN') toastSuccess('已营业');
if (next === 'PAUSED') toastSuccess('已临时闭店');
} catch {
/* request 已 toast */
} finally {
@@ -102,6 +112,7 @@ export default function StoreListPage() {
body: JSON.stringify({ status: 'CLOSED' }),
});
await loadStores();
toastSuccess('已永久闭店');
} catch {
/* request 已 toast */
} finally {
@@ -111,19 +122,33 @@ export default function StoreListPage() {
return (
<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-search">
<span className="material-symbols-outlined">search</span>
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="partner-chips">
<div className="partner-store-filter-row">
<label className="partner-store-filter-label" htmlFor="partner-store-status-filter">
</label>
<select
id="partner-store-status-filter"
className="partner-store-filter-select"
value={filter}
onChange={(e) => setFilter(e.target.value as StatusFilter)}
>
{FILTERS.map((f) => (
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
<option key={f.key} value={f.key}>
{f.label}
</button>
</option>
))}
</select>
</div>
</div>
@@ -145,87 +170,114 @@ export default function StoreListPage() {
const dim = currentStatus === 'CLOSED';
const storeName = String(s.name || '未命名门店');
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 (
<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>
<p className="label-md text-muted" style={{ marginBottom: 2 }}></p>
<h3 className="headline-md">{storeName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
{auditStatus !== 'APPROVED' && (
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}>
{storeAuditLabel(auditStatus)}
{auditStatus === 'REJECTED' && s.rejectReason ? `${String(s.rejectReason)}` : ''}
<div className="partner-store-card-main">
<p className="label-md text-muted" style={{ marginBottom: 2 }}>
</p>
)}
<h3 className="headline-md">{storeName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}>
{String(s.address || s.district || '')}
</p>
{auditStatus === 'REJECTED' && s.rejectReason ? (
<p
className="label-md"
style={{ marginTop: 8, color: 'var(--color-heritage-red)' }}
>
{storeAuditLabel(auditStatus)}{String(s.rejectReason)}
</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 className={`partner-status-pill partner-store-card-badge ${badge.pillClass}`}>
{badge.label}
</span>
) : (
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
{storeStatusLabel(currentStatus)}
</span>
)}
</div>
</div>
</Link>
{canMutate && (
<div className="partner-store-card-actions">
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
{canMutate ? (
<div className="partner-store-card-actions partner-store-card-actions--v3416">
{currentStatus !== 'CLOSED' ? (
<>
<label
className={`partner-store-switch${switchDisabled ? ' partner-store-switch--disabled' : ''}`}
>
</button>
<input
type="checkbox"
role="switch"
checked={switchOn}
disabled={switchDisabled}
aria-label={switchOn ? '营业中,点击临时闭店' : '临时闭店,点击营业'}
onChange={() => {
const next: StoreStatusValue = switchOn ? 'PAUSED' : 'OPEN';
void updateStatus(storeId, next, auditStatus);
}}
/>
<span className="partner-store-switch-track" aria-hidden />
<span className="partner-store-switch-text">
{switchOn ? '开启(营业中)' : '关闭(临时闭店)'}
</span>
</label>
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
disabled={busy || currentStatus === 'CLOSED'}
className="partner-store-close-btn"
disabled={busy}
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
>
</button>
{currentStatus === 'PAUSED' && (
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || !canOpen}
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
</>
) : null}
<Link
to={`/stores/${storeId}`}
className="partner-menu-icon"
style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}
>
{canOpen ? '开门营业' : '待审核通过'}
</button>
)}
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
edit
</span>
</Link>
</div>
)}
) : null}
</div>
);
})}
{closeTarget && (
<div className="partner-ship-modal-backdrop" 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>
<div
className="partner-ship-modal-backdrop"
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>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
+185
View File
@@ -304,6 +304,30 @@ html {
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 {
width: 100%;
padding: 16px;
@@ -1960,6 +1984,134 @@ nav.app-tabbar .app-tabbar-label {
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 {
background: var(--color-card);
border-radius: var(--radius-md);
@@ -2061,6 +2213,39 @@ nav.app-tabbar .app-tabbar-label {
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 ── */
.partner-detail-page {
padding-bottom: 96px;
+2 -1
View File
@@ -13,7 +13,8 @@
"jsx": "react-jsx",
"strict": true,
"paths": {
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"],
"@dukang/domain": ["../../packages/domain/src/index.ts"]
}
},
"include": ["src"]
+2
View File
@@ -9,9 +9,11 @@ export default defineConfig({
resolve: {
alias: {
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
},
},
server: {
host: true,
port: 5175,
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
@@ -148,8 +148,9 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
<label className="shop-packages-field">
<span className="shop-packages-label">使</span>
<input
<textarea
className="shop-packages-input"
rows={2}
placeholder="节假日除外"
value={item.usableTime || ''}
disabled={disabled}
@@ -213,8 +214,9 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
<label className="shop-packages-field">
<span className="shop-packages-label"></span>
<input
<textarea
className="shop-packages-input"
rows={2}
placeholder="不可叠加"
value={item.otherNotes || ''}
disabled={disabled}
+56
View File
@@ -112,6 +112,10 @@ export default function HomePage() {
const [authError, setAuthError] = useState('');
const [showOpenModal, setShowOpenModal] = useState(false);
const [opening, setOpening] = useState(false);
const pendingScanStartedRef = useRef(false);
@@ -309,6 +313,12 @@ export default function HomePage() {
setScanMsg('');
// 门店临时闭店/休息中(非营业状态)时,点击扫码直接提示,不进入扫码流程
if (status !== 'OPEN') {
setShowOpenModal(true);
return;
}
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
@@ -349,6 +359,25 @@ export default function HomePage() {
async function openStoreAndContinue() {
if (opening) return;
setOpening(true);
try {
await request('SHOP_H5', '/shop/store/status', {
method: 'PUT',
body: JSON.stringify({ status: 'OPEN' }),
});
setShowOpenModal(false);
setScanMsg('');
void loadDashboard(); // 刷新门店状态为营业中,扫码按钮可再次使用
} catch (e) {
setShowOpenModal(false);
setScanMsg(e instanceof Error ? e.message : '开启营业失败');
} finally {
setOpening(false);
}
}
async function startWechatAuth() {
setAuthLoading(true);
@@ -615,6 +644,33 @@ export default function HomePage() {
/>
{showOpenModal && (
<div className="shop-redeem-modal" role="dialog" aria-modal="true">
<div className="shop-redeem-modal-card">
<h4 className="shop-redeem-modal-title"></h4>
<p className="shop-redeem-modal-desc"></p>
<div className="shop-redeem-modal-actions">
<button
type="button"
className="shop-redeem-modal-cancel"
disabled={opening}
onClick={() => setShowOpenModal(false)}
>
</button>
<button
type="button"
className="shop-redeem-modal-confirm"
disabled={opening}
onClick={() => void openStoreAndContinue()}
>
{opening ? '开启中…' : '确认开启'}
</button>
</div>
</div>
</div>
)}
</PullToRefresh>
);
+63 -11
View File
@@ -20,6 +20,8 @@ export default function PhoneRedeemPage() {
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [confirmCooldown, setConfirmCooldown] = useState(0);
const [showOpenModal, setShowOpenModal] = useState(false);
const [opening, setOpening] = useState(false);
useEffect(() => {
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
@@ -36,15 +38,7 @@ export default function PhoneRedeemPage() {
return () => window.clearTimeout(timer);
}, [confirmCooldown]);
async function sendConfirmSms() {
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号');
return;
}
if (storeClosed) {
setMsg('门店未营业,无法核销');
return;
}
async function prepareDirectRedeem() {
const value = Number(amount);
if (!Number.isFinite(value) || value <= 0) {
setMsg('请输入有效核销金额');
@@ -69,6 +63,37 @@ export default function PhoneRedeemPage() {
}
}
async function sendConfirmSms() {
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号');
return;
}
if (storeClosed) {
setShowOpenModal(true);
return;
}
await prepareDirectRedeem();
}
async function openStoreAndContinue() {
if (opening) return;
setOpening(true);
try {
await request('SHOP_H5', '/shop/store/status', {
method: 'PUT',
body: JSON.stringify({ status: 'OPEN' }),
});
setStoreClosed(false);
setShowOpenModal(false);
await prepareDirectRedeem();
} catch (e) {
setShowOpenModal(false);
setMsg(e instanceof Error ? e.message : '开启营业失败');
} finally {
setOpening(false);
}
}
async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
@@ -114,7 +139,7 @@ export default function PhoneRedeemPage() {
<main className="shop-redeem-main">
{storeClosed && (
<p className="shop-redeem-error" style={{ marginBottom: 12 }}></p>
<p className="shop-redeem-error" style={{ marginBottom: 12 }}></p>
)}
<section className="shop-redeem-card">
@@ -181,7 +206,7 @@ export default function PhoneRedeemPage() {
<button
type="button"
className="shop-phone-code-btn"
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
disabled={loading || confirmCooldown > 0 || !canSendCode}
onClick={() => void sendConfirmSms()}
>
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
@@ -205,6 +230,33 @@ export default function PhoneRedeemPage() {
</div>
</section>
</main>
{showOpenModal && (
<div className="shop-redeem-modal" role="dialog" aria-modal="true">
<div className="shop-redeem-modal-card">
<h4 className="shop-redeem-modal-title"></h4>
<p className="shop-redeem-modal-desc"></p>
<div className="shop-redeem-modal-actions">
<button
type="button"
className="shop-redeem-modal-cancel"
disabled={opening}
onClick={() => setShowOpenModal(false)}
>
</button>
<button
type="button"
className="shop-redeem-modal-confirm"
disabled={opening}
onClick={() => void openStoreAndContinue()}
>
{opening ? '开启中…' : '确认开启'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+76 -14
View File
@@ -29,6 +29,8 @@ export default function RedeemConfirmPage() {
const [storeClosed, setStoreClosed] = useState(false);
const [failCount, setFailCount] = useState(0);
const [showWeakNet, setShowWeakNet] = useState(false);
const [showOpenModal, setShowOpenModal] = useState(false);
const [opening, setOpening] = useState(false);
useEffect(() => {
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
@@ -48,17 +50,19 @@ export default function RedeemConfirmPage() {
setToken(scanned);
}, [searchParams, navigate]);
useEffect(() => {
async function loadPreview() {
if (!token.trim()) {
setPreview(null);
return;
}
request<Preview>('SHOP_H5', '/shop/redeem/preview', {
try {
const p = await request<Preview>('SHOP_H5', '/shop/redeem/preview', {
method: 'POST',
body: JSON.stringify({ token }),
})
.then(setPreview)
.catch(async (e) => {
});
setPreview(p);
setMsg('');
} catch (e) {
setPreview(null);
setMsg(e instanceof Error ? e.message : '无法预览核销码');
const report = await reportRedeemFailure(token, 'preview', e);
@@ -68,14 +72,15 @@ export default function RedeemConfirmPage() {
} else if (report) {
setFailCount(report.failCount);
}
});
}
}
useEffect(() => {
void loadPreview();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function confirm() {
if (storeClosed) {
setMsg('门店未营业,无法核销');
return;
}
async function doConfirm() {
if (!token.trim()) {
setMsg('请先扫码获取核销码');
return;
@@ -103,6 +108,36 @@ export default function RedeemConfirmPage() {
}
}
async function confirm() {
if (storeClosed) {
setShowOpenModal(true);
return;
}
await doConfirm();
}
async function openStoreAndContinue() {
if (opening) return;
setOpening(true);
try {
await request('SHOP_H5', '/shop/store/status', {
method: 'PUT',
body: JSON.stringify({ status: 'OPEN' }),
});
setStoreClosed(false);
setShowOpenModal(false);
setMsg('');
// 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
await loadPreview();
await doConfirm();
} catch (e) {
setShowOpenModal(false);
setMsg(e instanceof Error ? e.message : '开启营业失败');
} finally {
setOpening(false);
}
}
const previewAmount = preview?.amount ?? 0;
const userLabel = preview?.user?.nickname || preview?.user?.phone || '—';
@@ -117,7 +152,7 @@ export default function RedeemConfirmPage() {
<main className="shop-redeem-main">
{storeClosed && (
<p className="shop-redeem-error" style={{ marginBottom: 12 }}></p>
<p className="shop-redeem-error" style={{ marginBottom: 12 }}></p>
)}
<section className="shop-redeem-card">
<div className="shop-redeem-banner">
@@ -189,13 +224,13 @@ export default function RedeemConfirmPage() {
<button
type="button"
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
disabled={loading || storeClosed || !preview}
disabled={loading || (!preview && !storeClosed)}
onClick={() => void confirm()}
>
<span className="material-symbols-outlined shop-fill-icon">
{loading ? 'sync' : 'check_circle'}
</span>
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : storeClosed ? '确认核销' : '加载中…'}</span>
</button>
<p className="shop-redeem-hint"></p>
</>
@@ -213,6 +248,33 @@ export default function RedeemConfirmPage() {
</span>
</div>
</main>
{showOpenModal && (
<div className="shop-redeem-modal" role="dialog" aria-modal="true">
<div className="shop-redeem-modal-card">
<h4 className="shop-redeem-modal-title"></h4>
<p className="shop-redeem-modal-desc"></p>
<div className="shop-redeem-modal-actions">
<button
type="button"
className="shop-redeem-modal-cancel"
disabled={opening}
onClick={() => setShowOpenModal(false)}
>
</button>
<button
type="button"
className="shop-redeem-modal-confirm"
disabled={opening}
onClick={() => void openStoreAndContinue()}
>
{opening ? '开启中…' : '确认开启'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+73 -3
View File
@@ -1519,6 +1519,7 @@
z-index: 40;
background: var(--color-surface);
border-bottom: 1px solid var(--color-surface-container-highest);
padding: 0 var(--space-page);
}
.shop-records-range-tabs {
@@ -1566,7 +1567,7 @@
.shop-records-status-chips {
display: flex;
gap: 8px;
padding: 0;
padding-bottom: 10px;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
@@ -1897,8 +1898,8 @@
}
.shop-withdraw-btn {
width: 100%;
margin-top: 12px;
width: calc(100% - 2 * var(--space-page));
margin: 12px var(--space-page) 0;
height: 44px;
border: none;
border-radius: 12px;
@@ -1916,11 +1917,80 @@
.shop-withdraw-msg {
margin-top: 10px;
padding: 0 var(--space-page);
font-size: 13px;
color: var(--color-aged-amber);
text-align: center;
}
/* ─── 休息中核销·开张确认弹窗 ─── */
.shop-redeem-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.45);
}
.shop-redeem-modal-card {
width: 100%;
max-width: 320px;
background: var(--color-surface);
border-radius: var(--radius-lg, 16px);
padding: 24px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
}
.shop-redeem-modal-title {
margin: 0 0 8px;
font-family: var(--font-headline);
font-size: 17px;
font-weight: 600;
color: var(--color-on-surface);
}
.shop-redeem-modal-desc {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.5;
color: var(--color-on-surface-variant);
}
.shop-redeem-modal-actions {
display: flex;
gap: 12px;
}
.shop-redeem-modal-cancel,
.shop-redeem-modal-confirm {
flex: 1;
padding: 11px 0;
border: none;
border-radius: 999px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.shop-redeem-modal-cancel {
background: var(--color-surface-container, #ececec);
color: var(--color-on-surface-variant);
}
.shop-redeem-modal-confirm {
background: var(--color-primary, #8b1a1a);
color: #fff;
}
.shop-redeem-modal-cancel:disabled,
.shop-redeem-modal-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ─── 门店套餐 ─── */
.shop-packages-page .shop-records-main {
padding-bottom: 24px;
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
},
},
server: {
host: true,
port: 5174,
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
},
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
},
},
server: {
host: true,
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
},
@@ -7,8 +7,13 @@ type ProductCarouselProps = {
alt: string;
variant?: 'home' | 'detail' | 'store';
previewable?: boolean;
/** cover=aspectFill 裁剪铺满;contain=aspectFit 缩放完整显示(门店门头固定区) */
imageFit?: 'cover' | 'contain';
/**
* cover=aspectFill 裁剪铺满(固定高度,可能裁切)
* contain=aspectFit 完整显示(固定高度,可能留白)
* adaptive=widthFix 按图片真实比例自适应高度,完整显示不裁剪(门店套餐详情用)
* 依赖 swiper 原生 auto-height:海报有多高,轮播就有多高,无裁切。
*/
imageFit?: 'cover' | 'contain' | 'adaptive';
};
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
@@ -24,7 +29,8 @@ export default function ProductCarousel({
const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
const isContain = imageFit === 'contain';
const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}`;
const isAdaptive = imageFit === 'adaptive';
const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}${isAdaptive ? ` ${prefix}-wrap--adaptive` : ''}`;
function previewAt(index: number) {
const urls = slides.filter(Boolean);
@@ -38,6 +44,8 @@ export default function ProductCarousel({
<Swiper
className={prefix}
circular={slides.length > 1}
autoHeight={isAdaptive}
{...(variant === 'detail' && slides.length > 1 ? { autoplay: true, interval: 3500 } : {})}
onChange={(e) => setActiveIndex(e.detail.current)}
>
{slides.map((src, index) => (
@@ -46,7 +54,7 @@ export default function ProductCarousel({
<Image
className={`${prefix}-image`}
src={src}
mode={isContain ? 'aspectFit' : 'aspectFill'}
mode={isAdaptive ? 'widthFix' : isContain ? 'aspectFit' : 'aspectFill'}
alt={alt}
onClick={previewable ? () => previewAt(index) : undefined}
/>
@@ -66,6 +74,9 @@ export default function ProductCarousel({
))}
</View>
) : null}
{variant === 'detail' && slides.length > 1 ? (
<View className={`${prefix}-counter`}>{activeIndex + 1}/{slides.length}</View>
) : null}
</View>
);
}
+45 -2
View File
@@ -1,4 +1,5 @@
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
const LANDLINE_PHONE_RE = /^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/;
export function normalizePhoneInput(value: string): string {
return value.replace(/\D/g, '').slice(0, 11);
@@ -18,6 +19,48 @@ export function validateMobilePhone(phone: string): { ok: boolean; message?: str
return { ok: true };
}
export function maskPhone(phone: string) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
function normalizeContactPhone(raw: string): string {
return String(raw ?? '')
.trim()
.replace(/\s+/g, '');
}
/**
* 脱敏展示:手机 138****8000;座机隐藏本地号中间四位,如 0379-12****78。
* 门店详情电话展示用(拨号仍走 toDialablePhone 明文)。
*/
export function maskPhone(phone: string) {
const normalized = normalizeContactPhone(phone);
if (!normalized) return '';
if (MOBILE_PHONE_RE.test(normalized)) {
const digits = normalized.replace(/\D/g, '');
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (LANDLINE_PHONE_RE.test(normalized)) {
const extMatch = normalized.match(/-(\d{1,6})$/);
const hasExt = !!extMatch && normalized.indexOf('-') !== normalized.lastIndexOf('-');
const ext = hasExt ? extMatch![1] : '';
const main = hasExt ? normalized.slice(0, -(ext.length + 1)) : normalized;
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);
// 隐藏本地号中间四位(保留区号,本号前后各留若干位):0379-12345678 → 0379-12****78
let maskedLocal: string;
if (local.length <= 4) {
maskedLocal = '*'.repeat(local.length);
} else {
const keep = local.length - 4;
const head = Math.max(1, Math.floor(keep / 2));
const tail = keep - head;
maskedLocal = `${local.slice(0, head)}${'*'.repeat(4)}${local.slice(local.length - tail)}`;
}
const joiner = main.includes('-') ? '-' : '';
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
}
return normalized.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
export function toDialablePhone(raw: string): string {
return String(raw ?? '').replace(/[\s-]/g, '');
}
@@ -15,7 +15,7 @@ import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import { maskPhone } from '../../lib/phone';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import { formatShanghaiDateTime } from '../../lib/datetime';
import {
@@ -108,6 +108,13 @@ function formatRedeemTime(input?: string | null) {
return formatShanghaiDateTime(input);
}
function formatPackagePriceYuan(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return '0';
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
}
function formatRedeemAmountYuan(amount: number | string) {
const n = typeof amount === 'number' ? amount : Number(amount);
if (!Number.isFinite(n)) return '0';
@@ -269,7 +276,7 @@ export default function StoreDetailPage() {
return;
}
track('store_phone_call', { storeId: store.id });
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
Taro.makePhoneCall({ phoneNumber: toDialablePhone(store.phone) }).catch(() => toast('无法拨打电话'));
}
function openMap() {
@@ -419,7 +426,12 @@ export default function StoreDetailPage() {
className="store-detail-package-list-item"
onClick={() => openPackageDetail(index)}
>
<View className="store-detail-package-list-row">
<Text className="store-detail-package-list-title">{pkg.name}</Text>
<Text className="store-detail-package-list-price">
&nbsp;&nbsp;&nbsp;&nbsp;¥{formatPackagePriceYuan(pkg.price)}
</Text>
</View>
</View>
))}
</View>
@@ -143,6 +143,7 @@ export default function StorePackageDetailPage() {
alt={pkg.name}
variant="detail"
previewable
imageFit="adaptive"
/>
</View>
) : null}
+28 -1
View File
@@ -14,10 +14,23 @@
.detail-carousel-wrap {
position: relative;
width: 100%;
aspect-ratio: 1;
aspect-ratio: 1.33; /* 保持4:3的比例 */
overflow: hidden;
}
/* adaptive 模式(方案 A):删除固定 1:1 比例与固定高度,
改用 swiper 原生 auto-height + image widthFix,海报有多高轮播就有多高,无裁切 */
.detail-carousel-wrap--adaptive {
/* aspect-ratio: auto; */
overflow: visible;
}
.detail-carousel-wrap--adaptive .detail-carousel,
.detail-carousel-wrap--adaptive .detail-carousel-item,
.detail-carousel-wrap--adaptive .detail-carousel-image {
height: 100%;
}
.detail-carousel {
width: 100%;
height: 100%;
@@ -57,6 +70,20 @@
background: var(--color-heritage-red);
}
.detail-carousel-counter {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 2;
padding: 2px 10px;
font-size: 12px;
line-height: 1.5;
color: #fff;
background: rgba(0, 0, 0, 0.5);
border-radius: 999px;
pointer-events: none;
}
.product-detail-info {
padding: var(--space-md) var(--space-page) var(--space-lg);
}
+51 -9
View File
@@ -74,6 +74,21 @@
background: #fff;
}
/* 套餐详情图右下角 1/N 计数(detail 变体复用 .detail-carousel-counter */
.detail-carousel-counter {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 2;
padding: 2px 10px;
font-size: 12px;
line-height: 1.5;
color: #fff;
background: rgba(0, 0, 0, 0.5);
border-radius: 999px;
pointer-events: none;
}
.store-detail-info-card {
margin: 0 var(--space-page) 16px;
position: relative;
@@ -281,13 +296,36 @@
padding-bottom: 0;
}
.store-detail-package-list-title {
/* 名称在左可换行;价格始终贴该行最右侧 */
.store-detail-package-list-row {
/* display: flex;
flex-wrap: wrap;
align-items: flex-start;
column-gap: 12px;
row-gap: 4px; */
display: block;
width: 100%;
}
.store-detail-package-list-title {
min-width: 0;
font-family: var(--font-headline);
font-size: 15px;
line-height: 1.5;
font-weight: 500;
color: var(--color-on-surface);
word-break: break-word;
display: inline;
}
.store-detail-package-list-price {
display: inline;
margin-left: auto;
font-size: 15px;
line-height: 1.5;
font-weight: 600;
color: var(--color-heritage-red, #a61d24);
text-align: right;
}
.store-detail-package-list-item:active {
@@ -315,15 +353,18 @@
padding: 8px 16px 4px 16px;
}
/* 与门店详情套餐列表一致:名称在左可换行;价格始终贴该行最右侧 */
.store-package-detail-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
column-gap: 16px;
row-gap: 4px;
width: 100%;
}
.store-package-detail-title {
flex: 1;
/* flex: 1 1 10em; */
min-width: 0;
font-family: var(--font-headline);
font-size: 20px;
@@ -334,13 +375,14 @@
}
.store-package-detail-price {
flex-shrink: 0;
font-size: 24px;
/* flex: 0 0 auto; */
margin-left: auto;
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #a61d24;
line-height: 1.4;
color: var(--color-heritage-red, #a61d24);
text-align: right;
white-space: nowrap;
}
.store-package-detail-store {
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# sync-prod-db-to-local.sh — 将线上(production)数据库同步到本地 Docker MySQL
#
# 原理:
# 1. 通过 SSH 隧道穿透到阿里云 RDS。生产 DB 主机通常只允许应用服务器访问,
# 本地开发机一般不在白名单,因此借道生产服务器 dukang-server 建立隧道。
# 2. 在本地 dukang-v1-mysql 容器内用 mysqldump 连接隧道端口,导出线上库。
# 3. 通过管道直接导入本地库(默认 dukang_haoke,即应用本地库名)。
#
# 前置条件:
# - deploy.env 中 DEPLOY_HOST / DEPLOY_USER / DEPLOY_PORT 已配置且可无密码 SSH
# - 本地 docker compose 已启动(dukang-v1-mysql 容器监听 6016
# - 远端 /opt/dukang/server/dukang-api/.env.production 含 DATABASE_URL
# - 本地已安装 dockerWindows 用 Docker Desktop,容器内自带 mysql 客户端)
#
# 用法:
# bash deploy/sync-prod-db-to-local.sh # 交互确认 + 先备份本地
# bash deploy/sync-prod-db-to-local.sh --yes # 跳过确认(仍先备份本地)
# bash deploy/sync-prod-db-to-local.sh --no-backup --yes
# bash deploy/sync-prod-db-to-local.sh --dry-run # 仅打印计划,不落库、不开隧道
# bash deploy/sync-prod-db-to-local.sh --help
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# ---------------- 默认参数 ----------------
LOCAL_CONTAINER="dukang-v1-mysql"
LOCAL_DB="dukang_haoke"
LOCAL_ROOT_PASSWORD="root"
TUNNEL_PORT=6018
REMOTE_ENV_FILE="/opt/dukang/server/dukang-api/.env.production"
HOST_ALIAS="host.docker.internal" # 容器内访问宿主机(Docker Desktop 默认支持)
DO_BACKUP=1
ASSUME_YES=0
DRY_RUN=0
# ---------------- 解析参数 ----------------
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_YES=1 ;;
--no-backup) DO_BACKUP=0 ;;
--dry-run) DRY_RUN=1 ;;
--help|-h) sed -n '3,30p' "$0"; exit 0 ;;
--tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;;
--local-db=*) LOCAL_DB="${arg#*=}" ;;
--container=*) LOCAL_CONTAINER="${arg#*=}" ;;
--host-alias=*) HOST_ALIAS="${arg#*=}" ;;
*) echo "未知参数: $arg" >&2; exit 2 ;;
esac
done
# ---------------- 加载 deploy.env ----------------
if [[ -f deploy.env ]]; then
# shellcheck disable=SC1091
source deploy.env
fi
DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT")
if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi
TARGET="$DEPLOY_USER@$DEPLOY_HOST"
# ---------------- 读取并解析远端 DATABASE_URL ----------------
echo "==> 读取线上数据库配置 ($TARGET:$REMOTE_ENV_FILE)"
# 用服务端 new URL 解析(密码可能含 @ / : 等特殊字符),base64 回传避免 shell 转义问题
PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF'
cat > /tmp/_parse_db.js <<'JSEOF'
const fs=require("fs");
const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim();
let u=l;
if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1);
const U=new URL(u);
const b=s=>Buffer.from(s).toString("base64");
const path=U.pathname.replace(/^\//,"").split("?")[0];
process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n");
JSEOF
node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js
NODEEOF
)"
if [[ -z "$PARSED" ]]; then
echo "无法读取远端 DATABASE_URL,中止。" >&2; exit 1
fi
IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED"
REMOTE_USER="$(echo "$_U" | base64 -d)"
REMOTE_HOST="$(echo "$_H" | base64 -d)"
REMOTE_PORT="$_P"
REMOTE_DB="$(echo "$_DB" | base64 -d)"
REMOTE_PASS="$(echo "$_PASS" | base64 -d)"
echo " 线上库: $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
# ---------------- 检查本地容器 ----------------
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
echo "本地容器 $LOCAL_CONTAINER 未运行。请先执行: cd deploy && docker compose up -d" >&2
exit 1
fi
# ---------------- 打印计划 ----------------
echo
echo "同步计划:"
echo " 源(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
echo " 目标(本地): root@$LOCAL_CONTAINER:$LOCAL_DB (对外端口 6016)"
echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
echo " 本地备份: $([[ $DO_BACKUP -eq 1 ]] && echo|| echo)"
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run) 已结束,未做任何修改。"
exit 0
fi
# ---------------- 确认 ----------------
if [[ $ASSUME_YES -ne 1 ]]; then
read -r -p "确认将【线上 $REMOTE_DB】覆盖同步到【本地 $LOCAL_DB】? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; }
fi
# ---------------- 备份本地 ----------------
if [[ $DO_BACKUP -eq 1 ]]; then
mkdir -p backups
BK="backups/local-${LOCAL_DB}-$(date +%Y%m%d-%H%M%S).sql"
echo "==> 备份本地库到 $BK"
docker exec "$LOCAL_CONTAINER" mysqldump -uroot -p"$LOCAL_ROOT_PASSWORD" --single-transaction "$LOCAL_DB" > "$BK"
echo " 备份完成 ($(wc -c < "$BK") bytes)"
fi
# ---------------- 确保本地目标库存在 ----------------
docker exec "$LOCAL_CONTAINER" mysql -uroot -p"$LOCAL_ROOT_PASSWORD" \
-e "CREATE DATABASE IF NOT EXISTS \`$LOCAL_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# ---------------- 建立 SSH 隧道 ----------------
CTL="/tmp/sync-prod-db-$$.sock"
echo "==> 建立 SSH 隧道 $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
# 等待隧道就绪
ready=0
for i in 1 2 3 4 5 6 7 8 9 10; do
if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqladmin -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then
ready=1; break
fi
sleep 1
done
if [[ $ready -ne 1 ]]; then
echo "隧道未就绪,中止。" >&2
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
exit 1
fi
# ---------------- 同步(线上 -> 本地,管道直导) ----------------
echo "==> 开始同步(管道直导,不落临时文件)"
echo " 提示:同步前建议本地 Prisma 已追平线上 schemapnpm db:push 或迁移),否则可能因表结构差异报错。"
set +e
docker exec -i -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqldump -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \
--lock-tables=0 --add-drop-table --skip-triggers --no-create-db \
--skip-routines --skip-events --column-statistics=0 --no-tablespaces --set-gtid-purged=OFF \
"$REMOTE_DB" \
| docker exec -i "$LOCAL_CONTAINER" mysql -uroot -p"$LOCAL_ROOT_PASSWORD" "$LOCAL_DB"
RC=${PIPESTATUS[0]}
set -e
# ---------------- 关闭隧道 ----------------
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
if [[ $RC -ne 0 ]]; then
echo "同步失败 (mysqldump 退出码 $RC)。本地库可能处于不一致状态。" >&2
[[ $DO_BACKUP -eq 1 ]] && echo "请用备份恢复: $BK" >&2
exit 1
fi
echo "==> 同步完成 ✅ 本地 $LOCAL_DB 现已是线上 $REMOTE_DB 的副本。"
+6
View File
@@ -0,0 +1,6 @@
$sid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-21-4178830254-1499118431-1315729128-3960883439')
$acl = Get-Acl 'C:\Users\Jacy\0projects\2026062901-dukang-v1\.git'
$rules = $acl.Access | Where-Object { $_.IdentityReference -eq $sid -and $_.AccessControlType -eq 'Deny' }
foreach ($r in $rules) { $acl.RemoveAccessRule($r) | Out-Null }
$acl | Set-Acl 'C:\Users\Jacy\0projects\2026062901-dukang-v1\.git'
Write-Host 'done'
+8
View File
@@ -4,6 +4,14 @@
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts",
"require": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
+1
View File
@@ -351,3 +351,4 @@ export function orderTabToStatuses(tab: string): string[] | undefined {
export * from './city-partner';
export * from './dev-plan';
export * from './support-ticket';
export * from './phone';
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import {
isLandlinePhone,
isMobilePhone,
isStoreContactPhone,
maskContactPhone,
normalizeContactPhone,
toDialablePhone,
} from './phone';
describe('store contact phone', () => {
it('accepts mainland mobile numbers', () => {
expect(isMobilePhone('13800138000')).toBe(true);
expect(isStoreContactPhone('13800138000')).toBe(true);
expect(isStoreContactPhone(' 13800138000 ')).toBe(true);
});
it('accepts landlines with or without hyphens and optional extension', () => {
expect(isLandlinePhone('0379-8888888')).toBe(true);
expect(isLandlinePhone('010-12345678')).toBe(true);
expect(isLandlinePhone('03798888888')).toBe(true);
expect(isLandlinePhone('01012345678')).toBe(true);
expect(isLandlinePhone('0379-8888888-12')).toBe(true);
expect(isStoreContactPhone('0379-8888 888')).toBe(true);
});
it('rejects login-style invalid and incomplete numbers', () => {
expect(isStoreContactPhone('')).toBe(false);
expect(isStoreContactPhone('12345678')).toBe(false);
expect(isStoreContactPhone('12345678901')).toBe(false);
expect(isStoreContactPhone('400-123-4567')).toBe(false);
expect(isMobilePhone('0379-8888888')).toBe(false);
});
it('normalizes spaces and strips dial punctuation', () => {
expect(normalizeContactPhone(' 0379-8888 888 ')).toBe('0379-8888888');
expect(toDialablePhone('0379-8888888')).toBe('03798888888');
expect(toDialablePhone('010 1234 5678')).toBe('01012345678');
});
it('masks mobile and landline for display', () => {
expect(maskContactPhone('13800138000')).toBe('138****8000');
expect(maskContactPhone('0379-8888888')).toBe('0379-****888');
expect(maskContactPhone('010-12345678')).toBe('010-****5678');
expect(maskContactPhone('03798888888')).toBe('0379****888');
expect(maskContactPhone('0379-8888888-12')).toBe('0379-****888-12');
expect(maskContactPhone('')).toBe('—');
});
});
+71
View File
@@ -0,0 +1,71 @@
/** 11 位大陆手机号(登录凭证) */
export const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
/**
* 国内座机:区号 0 开头(3~4 位),本地号 7~8 位,允许 `-`,可选分机。
* 例:0379-8888888、010-12345678、03798888888
*/
export const LANDLINE_PHONE_RE = /^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/;
export const STORE_CONTACT_PHONE_HINT = '请输入正确的联系电话(手机号或座机,如 0379-8888888';
export function normalizeContactPhone(raw: string): string {
return String(raw ?? '')
.trim()
.replace(/\s+/g, '');
}
export function isMobilePhone(raw: string): boolean {
return MOBILE_PHONE_RE.test(normalizeContactPhone(raw));
}
export function isLandlinePhone(raw: string): boolean {
return LANDLINE_PHONE_RE.test(normalizeContactPhone(raw));
}
/** 门店对外联系电话:手机号或座机 */
export function isStoreContactPhone(raw: string): boolean {
const s = normalizeContactPhone(raw);
return isMobilePhone(s) || isLandlinePhone(s);
}
/** 拨号用:去掉空格和横线 */
export function toDialablePhone(raw: string): string {
return String(raw ?? '').replace(/[\s-]/g, '');
}
/**
* 对外联系电话脱敏展示。
* - 手机:138****8000
* - 座机:保留区号,本地号中间打码,如 0379-****888 / 010-****5678
* - 带分机时保留分机后缀
*/
export function maskContactPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim();
if (!raw) return '—';
const normalized = normalizeContactPhone(raw);
if (isMobilePhone(normalized)) {
const digits = normalized.replace(/\D/g, '');
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (isLandlinePhone(normalized)) {
const extMatch = normalized.match(/-(\d{1,6})$/);
const hasExt = !!extMatch && normalized.indexOf('-') !== normalized.lastIndexOf('-');
const ext = hasExt ? extMatch![1] : '';
const main = hasExt ? normalized.slice(0, -(ext.length + 1)) : normalized;
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 = normalized.replace(/\D/g, '');
if (digits.length >= 11) return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
if (digits.length >= 7) return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
return '****';
}
+4 -1
View File
@@ -1,4 +1,4 @@
/** HQ 权限目录(权限分配页勾选源) */
/** HQ 权限目录(权限分配页勾选源) */
export const HQ_PERMISSION_CATALOG = [
{ key: 'dashboard', label: '概览', group: '业务' },
{ key: 'users', label: '用户管理', group: '业务' },
@@ -30,6 +30,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
{ key: 'system_settings_wechat_mini', label: '微信小程序', group: '系统设置' },
{ key: 'system_settings_wechat_mini_share', label: '小程序分享配置', group: '系统设置' },
{ key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' },
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
@@ -60,6 +61,7 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
sms: 'system_settings_sms',
wechat: 'system_settings_wechat',
wechat_mini: 'system_settings_wechat_mini',
wechat_mini_share: 'system_settings_wechat_mini_share',
oss: 'system_settings_oss',
app: 'system_settings_app',
deploy: 'system_settings_deploy',
@@ -121,6 +123,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'resources',
'logs',
'system_settings_wechat_mini',
'system_settings_wechat_mini_share',
],
FINANCE: [
'dashboard',
@@ -105,6 +105,10 @@ export interface StorePackageAuditAction {
rejectReason?: string;
}
export interface StorePackageAuditSummaryDto {
pendingCount: number;
}
export interface CreatePackageDisputeRequest {
storeId: string;
remark?: string;
+4
View File
@@ -64,6 +64,10 @@ export type ClientRuntimeConfig = {
qualificationDisclosureUrl?: string;
/** 总部客服电话 */
customerServicePhone?: string;
/** 合伙人入驻:企微客服二维码图片 URL */
partnerOnboardCsQrUrl?: string | null;
/** 合伙人入驻:企微客服提示文案 */
partnerOnboardCsHint?: string | null;
/** 小程序各场景分享文案/图 */
share?: MiniShareRuntime;
};
+6
View File
@@ -26,6 +26,9 @@ importers:
'@ant-design/icons':
specifier: ^5.5.1
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@dukang/domain':
specifier: workspace:*
version: link:../../packages/domain
'@dukang/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
@@ -78,6 +81,9 @@ importers:
'@dukang/client-logging':
specifier: workspace:*
version: link:../../packages/client-logging
'@dukang/domain':
specifier: workspace:*
version: link:../../packages/domain
'@dukang/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
+3
View File
@@ -1248,7 +1248,10 @@ model Store {
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
name String @db.VarChar(128)
/// 门店主账号登录手机号(老板);与 StoreAccount.phone 对齐
phone String @db.VarChar(20)
/// 对外联系电话(店长等);C 端拨号展示用;空则回退 phone
contactPhone String? @map("contact_phone") @db.VarChar(20)
province String @db.VarChar(32)
cityName String @map("city_name") @db.VarChar(32)
district String @db.VarChar(32)
@@ -44,11 +44,36 @@ export function mapOrderCompat<T extends OrderLike & {
};
}
export function mapStoreCompat<T extends { coverResource?: { url?: string } | null }>(store: T) {
return {
/** 对外联系电话;未单独配置时回退登录手机号 */
export function resolveStoreContactPhone(store: {
phone?: string | null;
contactPhone?: string | null;
}): string {
const contact = String(store.contactPhone ?? '').trim();
if (contact) return contact;
return String(store.phone ?? '').trim();
}
export function mapStoreCompat<
T extends {
coverResource?: { url?: string } | null;
phone?: string | null;
contactPhone?: string | null;
},
>(store: T, opts?: { /** C 端:phone 字段对外可拨打号码 */ publicDial?: boolean }) {
const contactPhone = resolveStoreContactPhone(store) || null;
const mapped = {
...store,
coverUrl: store.coverResource?.url ?? null,
contactPhone,
};
if (opts?.publicDial) {
return {
...mapped,
phone: contactPhone || store.phone || null,
};
}
return mapped;
}
export function mapStatusLogCompat(events: CommonEvent[]) {
@@ -0,0 +1,40 @@
/**
* 门店签约合同(CommonResource bizType='CONTRACT')多附件工具。
*
* 历史上合同只存单条记录(字段 contractUrl:string),现改为支持多张照片 / PDF。
* 入参同时兼容新的 contractUrls:string[] 与旧的 contractUrl:string。
*/
export const MAX_CONTRACT_FILES = 20;
/**
* 归一化合同附件地址:去空白、去重、限制数量。
* @param urls 新字段 contractUrls
* @param legacy 旧字段 contractUrl(仅在 urls 未提供时生效)
*/
export function normalizeContractUrls(
urls?: unknown,
legacy?: string | null,
): string[] {
const raw: unknown[] = Array.isArray(urls)
? urls
: legacy != null
? [legacy]
: [];
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
const url = String(item ?? '').trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
if (out.length >= MAX_CONTRACT_FILES) break;
}
return out;
}
/** PDF 存 FILE,其余(合同照片)存 IMAGE,便于前端按图片预览 */
export function contractMediaType(url: string): 'FILE' | 'IMAGE' {
return /\.pdf(\?|$)/i.test(url) ? 'FILE' : 'IMAGE';
}
@@ -213,6 +213,23 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
placeholder: '13203801799',
description: 'C 端联系客服拨号号码',
},
{
key: 'PARTNER_ONBOARD_CS_QR_URL',
label: '合伙人入驻 · 企微客服二维码',
group: G.wechat_mini,
type: 'image',
requiresRestart: false,
description: '合伙人 H5 录入门店提交前展示;未配置时禁止提交入驻',
},
{
key: 'PARTNER_ONBOARD_CS_HINT',
label: '合伙人入驻 · 客服提示文案',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: '使用问题、提现问题等随时可联系【杜康好客】客服',
description: '二维码下方说明;留空用默认文案',
},
{
key: 'MOCK_SMS_FIXED_CODE',
label: 'Mock 短信固定验证码',
+4 -3
View File
@@ -86,12 +86,13 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
const port = process.env.PORT || 3010;
const port = Number(process.env.PORT || 3010);
const host = process.env.HOST || '0.0.0.0';
const cfg = loadAppConfig();
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`);
await app.listen(port);
console.log(`dukang-api listening on http://localhost:${port}/api/v1`);
await app.listen(port, host);
console.log(`dukang-api listening on http://${host}:${port}/api/v1`);
}
bootstrap();
@@ -37,6 +37,10 @@ export class ClientConfigController {
brandLogoMarkUrl: brand.brandLogoMarkUrl,
qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
customerServicePhone: brand.customerServicePhone,
partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null,
partnerOnboardCsHint:
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
'使用问题、提现问题等随时可联系【杜康好客】客服',
share,
};
}
@@ -5,7 +5,6 @@ import { AlertService } from '../../common/alert/alert.service';
import type { AlertLevel } from '../../common/alert/alert.constants';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import type { ReportClientErrorDto } from './dto/client-error.dto';
import { SupportTicketService } from './support-ticket.service';
const WECOM_LEVELS = new Set(['fatal', 'error']);
@@ -16,7 +15,6 @@ export class ClientErrorService {
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
private readonly supportTicket: SupportTicketService,
) {}
async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) {
@@ -109,25 +107,6 @@ export class ClientErrorService {
);
}
if (dto.category === 'validation_error') {
const apiPath =
dto.extra && typeof dto.extra.url === 'string' ? dto.extra.url.slice(0, 256) : undefined;
void this.supportTicket
.createFromClientValidation({
clientApp,
message,
pagePath,
apiPath,
actorLabel:
user?.actorId != null ? `${user.actorType}:${String(user.actorId)}` : undefined,
})
.catch((e) => {
this.logger.warn(
`auto support ticket failed: ${e instanceof Error ? e.message : String(e)}`,
);
});
}
return { ok: true };
}
}
@@ -38,17 +38,8 @@ function generateSupportTicketNo() {
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}
const AUTO_VALIDATION_TICKET_APPS = new Set([
'USER_H5',
'USER_MINI',
'SHOP_H5',
'PARTNER_H5',
]);
@Injectable()
export class SupportTicketService {
private systemCreatorCache: { id: bigint; name: string } | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
@@ -105,66 +96,6 @@ export class SupportTicketService {
return serializeBigInt(mapSupportTicketRow(ticket));
}
/** 客户端 400 验证错误自动建单(1 小时内同标题去重) */
async createFromClientValidation(input: {
clientApp: string;
message: string;
pagePath?: string;
apiPath?: string;
actorLabel?: string;
}) {
if (!AUTO_VALIDATION_TICKET_APPS.has(input.clientApp)) {
return { skipped: true as const, reason: 'unsupported_app' as const };
}
const title = `[客户端验证] ${input.clientApp}${input.pagePath ? ` · ${input.pagePath}` : ''} · ${input.message.slice(0, 60)}`;
const oneHourAgo = new Date(Date.now() - 3600_000);
const existing = await this.prisma.commonSupportTicket.findFirst({
where: { title, createdAt: { gte: oneHourAgo } },
select: { id: true },
});
if (existing) {
return { skipped: true as const, ticketId: existing.id.toString() };
}
const creator = await this.resolveSystemCreator();
const content = [
`端:${input.clientApp}`,
input.pagePath ? `页面:${input.pagePath}` : null,
input.apiPath ? `接口:${input.apiPath}` : null,
input.actorLabel ? `用户:${input.actorLabel}` : null,
'',
input.message,
]
.filter(Boolean)
.join('\n');
const ticket = await this.create(
{
ticketType: 'BUG',
title,
content,
remark: '客户端验证错误自动上报',
},
creator,
);
return { skipped: false as const, ticketId: String(ticket.id) };
}
private async resolveSystemCreator() {
if (this.systemCreatorCache) return this.systemCreatorCache;
const account = await this.prisma.hqAccount.findFirst({
where: { adminRole: 'SUPER_ADMIN', status: 'ACTIVE' },
orderBy: { id: 'asc' },
select: { id: true },
});
if (!account) {
throw new BadRequestException('未找到系统管理员账号,无法自动创建工单');
}
this.systemCreatorCache = { id: account.id, name: '系统自动' };
return this.systemCreatorCache;
}
async update(id: bigint, dto: UpdateSupportTicketDto) {
const ticket = await this.getOrThrow(id);
if (ticket.status !== 'PENDING_REVIEW') {
@@ -304,12 +304,34 @@ export class AuthService {
}
}
/**
*
* 1) status !== ACTIVEDISABLED
* 2) bindingStatus !== ACTIVEPAUSED
*
*/
private async assertPartnerAccountActive(account: {
id: bigint;
status: string;
isPrimary: number;
bindingStatus?: string | null;
}): Promise<void> {
if (account.status !== 'ACTIVE') {
throw new BadRequestException('该账号已暂停使用,请联系客服人员');
}
const primary =
account.isPrimary === 1 ? account : await this.resolvePrimaryAccount(account.id);
if (primary.bindingStatus && primary.bindingStatus !== 'ACTIVE') {
throw new BadRequestException('该合伙人合作已暂停,请联系客服人员');
}
}
private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
await this.assertPartnerAccountActive(account);
return account;
}
@@ -1061,7 +1083,7 @@ export class AuthService {
where: { phone: normalizedPhone },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
await this.assertPartnerAccountActive(account);
await this.syncTestFlagByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({
@@ -1723,9 +1745,7 @@ export class AuthService {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
if (account.status !== 'ACTIVE') {
throw new BadRequestException('合伙人账号已停用');
}
await this.assertPartnerAccountActive(account);
account = await this.prisma.partnerAccount.update({
where: { id: account.id },
@@ -1,9 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { validateBusinessHours } from '@dukang/domain';
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { StoreCategoryService } from '../store/store-category.service';
@@ -92,6 +93,17 @@ export class AdminStoresService {
}),
this.prisma.store.count({ where }),
]);
// 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用
const pendingByStore = new Map<string, string>();
if (items.length) {
const pendingReqs = await this.prisma.storePackageChangeRequest.findMany({
where: { storeId: { in: items.map((s) => s.id) }, status: 'PENDING' },
select: { id: true, storeId: true },
});
for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
}
return serializeBigInt({
items: items.map((s) => {
const { visibilityPhones, ...rest } = s;
@@ -99,6 +111,8 @@ export class AdminStoresService {
...rest,
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((p) => p.phone),
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
partner: s.partnerAccount,
account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined,
@@ -146,8 +160,10 @@ export class AdminStoresService {
visibilityPhones: visibilityPhones.map((p) => p.phone),
partner: store.partnerAccount,
account: store.bindings[0]?.storeAccount ?? null,
/** 门店端登录手机号(store_account.phone与 store.phone 对齐 */
/** 门店端登录手机号(store_account.phone与 store.phone 对齐 */
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
/** 对外联系电话;空则回退登录号 */
contactPhone: store.contactPhone?.trim() || store.phone,
bindings: undefined,
media,
audits,
@@ -274,12 +290,20 @@ export class AdminStoresService {
if (dto.phone !== undefined) {
const normalizedPhone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
throw new BadRequestException('请输入正确的手机号');
if (!isMobilePhone(normalizedPhone)) {
throw new BadRequestException('请输入正确的登录手机号');
}
}
if (dto.contactPhone !== undefined) {
const contact = dto.contactPhone.trim();
if (contact && !isStoreContactPhone(contact)) {
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
}
}
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
const normalizedContactPhone =
dto.contactPhone !== undefined ? dto.contactPhone.trim() || null : undefined;
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
const nextEnabled =
dto.visibilityWhitelistEnabled !== undefined
@@ -296,7 +320,7 @@ export class AdminStoresService {
const needAccountSync =
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
// 登录凭证在 store_account.phone必须与门店展示手机号同步
// 登录凭证在 store_account.phone与 store.phone(老板登录号)同步,不与对外联系电话绑定
let primaryBinding: {
storeAccount: { id: bigint; phone: string; name: string } | null;
} | null = null;
@@ -346,6 +370,9 @@ export class AdminStoresService {
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
...(normalizedContactPhone !== undefined
? { contactPhone: normalizedContactPhone }
: {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.benefitUsageRule !== undefined
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
@@ -433,24 +460,23 @@ export class AdminStoresService {
}
}
if (dto.contractUrl !== undefined) {
const contractUrl = dto.contractUrl?.trim() || '';
if (dto.contractUrls !== undefined || dto.contractUrl !== undefined) {
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
if (contractUrl) {
const isPdf = /\.pdf(\?|$)/i.test(contractUrl);
for (let i = 0; i < contractUrls.length; i++) {
await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'CONTRACT',
mediaType: isPdf ? 'FILE' : 'IMAGE',
mediaType: contractMediaType(contractUrls[i]),
ossBucket: 'legacy',
ossKey: contractUrl,
url: contractUrl,
sortOrder: 0,
ossKey: contractUrls[i],
url: contractUrls[i],
sortOrder: i,
},
});
}
@@ -492,7 +518,7 @@ export class AdminStoresService {
async createStore(dto: CreateStoreDto) {
const normalizedPhone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
if (!isMobilePhone(normalizedPhone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existingAccount = await this.prisma.storeAccount.findUnique({
@@ -562,6 +588,11 @@ export class AdminStoresService {
? !!dto.isTest
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
const contactPhoneRaw = dto.contactPhone?.trim() || normalizedPhone;
if (!isStoreContactPhone(contactPhoneRaw)) {
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
}
const store = await this.prisma.store.create({
data: {
cityId: city.id,
@@ -570,6 +601,7 @@ export class AdminStoresService {
categoryId,
name: dto.name,
phone: normalizedPhone,
contactPhone: contactPhoneRaw,
province: dto.province ?? city.province,
cityName: dto.city ?? city.name,
district: dto.district ?? '',
@@ -623,16 +655,18 @@ export class AdminStoresService {
});
}
if (dto.contractUrl) {
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
for (let i = 0; i < contractUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'CONTRACT',
mediaType: 'FILE',
mediaType: contractMediaType(contractUrls[i]),
ossBucket: 'legacy',
ossKey: dto.contractUrl,
url: dto.contractUrl,
ossKey: contractUrls[i],
url: contractUrls[i],
sortOrder: i,
},
});
}
@@ -6,6 +6,28 @@ import type { AdminUsersQueryDto } from './dto/admin-query.dto';
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
/** 权益统计口径:VOID(退款作废)不计入,与 C 端 listCoupons 一致 */
const BENEFIT_STAT_STATUSES = ['ACTIVE', 'USED_UP'] as const;
export type UserBenefitStat = {
/** 累计获得(含已使用) */
benefitTotalAmount: number;
/** 已使用(已核销) */
benefitUsedAmount: number;
/** 剩余未使用(可核销余额) */
benefitBalance: number;
};
const EMPTY_BENEFIT_STAT: UserBenefitStat = {
benefitTotalAmount: 0,
benefitUsedAmount: 0,
benefitBalance: 0,
};
function toAmount(v: Prisma.Decimal | number | null | undefined) {
return v == null ? 0 : Number(v);
}
function mapAdminUserRow(u: {
id: bigint;
userNo: string;
@@ -23,7 +45,7 @@ function mapAdminUserRow(u: {
createdAt: Date;
updatedAt: Date;
_count: { orders: number };
}) {
}, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) {
return {
id: u.id,
userNo: u.userNo,
@@ -42,6 +64,7 @@ function mapAdminUserRow(u: {
createdAt: u.createdAt,
updatedAt: u.updatedAt,
orderCount: u._count.orders,
...benefit,
};
}
@@ -90,14 +113,37 @@ export class AdminUsersService {
this.prisma.user.count({ where }),
]);
const benefitMap = await this.loadBenefitStats(items.map((u) => u.id));
return serializeBigInt({
items: items.map((u) => mapAdminUserRow(u)),
items: items.map((u) => mapAdminUserRow(u, benefitMap.get(u.id.toString()))),
total,
page,
pageSize,
});
}
/** 批量聚合当前页用户的好客权益金额(累计获得 / 已使用 / 剩余) */
private async loadBenefitStats(userIds: bigint[]): Promise<Map<string, UserBenefitStat>> {
const map = new Map<string, UserBenefitStat>();
if (!userIds.length) return map;
const grouped = await this.prisma.benefitCoupon.groupBy({
by: ['userId'],
where: { userId: { in: userIds }, status: { in: [...BENEFIT_STAT_STATUSES] } },
_sum: { totalAmount: true, usedAmount: true, balance: true },
});
for (const row of grouped) {
map.set(row.userId.toString(), {
benefitTotalAmount: toAmount(row._sum.totalAmount),
benefitUsedAmount: toAmount(row._sum.usedAmount),
benefitBalance: toAmount(row._sum.balance),
});
}
return map;
}
async detail(id: bigint) {
const user = await this.prisma.user.findUnique({
where: { id },
@@ -128,8 +174,11 @@ export class AdminUsersService {
});
}
const benefitMap = await this.loadBenefitStats([user.id]);
return serializeBigInt({
...user,
...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT),
wechatVerified: !!user.wxOpenId,
mergedFromCount: user._count.mergedFrom,
orderCount: user._count.orders,
@@ -38,6 +38,11 @@ export class CreateStoreDto {
@IsNotEmpty()
phone: string;
/** 对外联系电话(店长);不传则默认与登录手机号相同 */
@IsOptional()
@IsString()
contactPhone?: string;
@IsString()
@IsNotEmpty()
categoryId: string;
@@ -100,10 +105,18 @@ export class CreateStoreDto {
@ArrayMaxSize(20)
envPhotoUrls?: string[];
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
@IsOptional()
@IsString()
contractUrl?: string;
/** 签约合同(支持多张照片 / PDF) */
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
contractUrls?: string[];
@IsOptional()
@IsNumber()
@Min(0)
@@ -162,6 +175,11 @@ export class UpdateStoreDto {
@IsString()
phone?: string;
/** 对外联系电话(店长) */
@IsOptional()
@IsString()
contactPhone?: string;
@IsOptional()
@IsString()
intro?: string;
@@ -180,10 +198,18 @@ export class UpdateStoreDto {
@ArrayMaxSize(20)
envPhotoUrls?: string[];
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
@IsOptional()
@IsString()
contractUrl?: string | null;
/** 签约合同(支持多张照片 / PDF),传空数组即清空 */
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
contractUrls?: string[];
@IsOptional()
@IsString()
address?: string;
@@ -1708,6 +1708,9 @@ export class SettlementService {
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('酒厂对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
if (Number(bill.wineryAmount) === 0) {
throw new BadRequestException('应付为 0,无需打款');
}
const updated = await this.prisma.wineryBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
@@ -1749,11 +1752,17 @@ export class SettlementService {
'配送类型',
'实付金额',
'酒厂比例',
'酒厂应付',
'应付',
'支付时间',
'账单状态',
].join(',');
const rows: string[] = [];
const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => {
if (Number(b.wineryAmount) === 0) return '无需打款';
if (b.status === 'PAID') return '已打款';
if (b.status === 'UNPAID') return '未打款';
return b.status;
};
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
@@ -1766,7 +1775,7 @@ export class SettlementService {
Number(b.wineryRate),
Number(b.wineryAmount),
'',
b.status,
statusLabel(b),
].join(','),
);
continue;
@@ -1782,7 +1791,7 @@ export class SettlementService {
Number(b.wineryRate),
Number(item.wineryAmount),
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
statusLabel(b),
].join(','),
);
}
@@ -1798,7 +1807,15 @@ export class SettlementService {
month?: number;
}): Prisma.WineryBillWhereInput {
const where: Prisma.WineryBillWhereInput = {};
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
if (query.status === 'NO_PAYMENT_NEEDED') {
where.wineryAmount = 0;
} else if (query.status === 'UNPAID') {
where.status = 'UNPAID';
where.wineryAmount = { gt: 0 };
} else if (query.status === 'PAID') {
where.status = 'PAID';
where.wineryAmount = { gt: 0 };
}
if (query.year && query.month) {
const start = new Date(query.year, query.month - 1, 1);
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
@@ -70,6 +70,11 @@ export class AdminStorePackageController {
export class AdminStorePackageAuditController {
constructor(private readonly packages: StorePackageService) {}
@Get('summary')
summary() {
return this.packages.adminAuditSummary();
}
@Get()
list(
@Query('status') status?: string,
@@ -376,4 +376,11 @@ export class StorePackageService {
});
return serializeBigInt({ id: requestId.toString(), status: 'APPROVED' });
}
async adminAuditSummary() {
const pendingCount = await this.prisma.storePackageChangeRequest.count({
where: { status: 'PENDING' },
});
return { pendingCount };
}
}
@@ -7,11 +7,12 @@ import {
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { validateBusinessHours } from '@dukang/domain';
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
import { parseBigIntParam } from '../../common/parse-bigint';
import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
@@ -179,11 +180,14 @@ export class StoreService {
for (const store of visible) {
const coords = await this.ensureStoreCoordinates(store);
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
const mapped = mapStoreCompat({
const mapped = mapStoreCompat(
{
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
});
},
{ publicDial: true },
);
const distanceMeters =
hasUser && coords
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
@@ -230,7 +234,8 @@ export class StoreService {
});
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
mapStoreCompat({
mapStoreCompat(
{
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
@@ -252,7 +257,9 @@ export class StoreService {
sortOrder: p.sortOrder,
};
}),
}),
},
{ publicDial: true },
),
);
}
@@ -275,7 +282,7 @@ export class StoreService {
include: { category: true, coverResource: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return serializeBigInt(stores.map(mapStoreCompat));
return serializeBigInt(stores.map((s) => mapStoreCompat(s)));
}
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
@@ -307,10 +314,10 @@ export class StoreService {
async partnerCheckStorePhone(phone: string) {
const normalizedPhone = phone.trim();
if (!normalizedPhone) {
return { available: false, message: '请填写联系电话' };
return { available: false, message: '请填写门店登录手机号' };
}
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
return { available: false, message: '联系电话须为11位手机号' };
return { available: false, message: '门店登录手机号须为11位手机号' };
}
const existingAccount = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
@@ -360,14 +367,25 @@ export class StoreService {
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
const contactPhoneRaw =
body.contactPhone != null && String(body.contactPhone).trim()
? String(body.contactPhone).trim()
: normalizedPhone;
if (!isStoreContactPhone(contactPhoneRaw)) {
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
}
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
const contractUrls = normalizeContractUrls(
body.contractUrls,
body.contractUrl != null ? String(body.contractUrl) : null,
);
if (!coverUrl) throw new BadRequestException('请上传门头照');
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
if (!contractUrl) throw new BadRequestException('请上传签约合同');
if (!contractUrls.length) throw new BadRequestException('请上传签约合同');
const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
@@ -416,6 +434,7 @@ export class StoreService {
categoryId,
name: String(body.name),
phone: normalizedPhone,
contactPhone: contactPhoneRaw,
province: String(body.province ?? city.province ?? '河南省'),
cityName: String(body.city ?? city.name ?? '郑州市'),
district: String(body.district ?? ''),
@@ -471,16 +490,17 @@ export class StoreService {
});
}
if (contractUrl) {
for (let i = 0; i < contractUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'CONTRACT',
mediaType: 'FILE',
mediaType: contractMediaType(contractUrls[i]),
ossBucket,
ossKey: contractUrl,
url: contractUrl,
ossKey: contractUrls[i],
url: contractUrls[i],
sortOrder: i,
},
});
}
@@ -614,7 +634,13 @@ export class StoreService {
}
const name = body.name !== undefined ? String(body.name).trim() : undefined;
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
// 合伙人资料修改:联系电话 → contactPhone(兼容旧客户端把 phone 当联系电话提交)
const contactPhone =
body.contactPhone !== undefined
? String(body.contactPhone).trim()
: body.phone !== undefined
? String(body.phone).trim()
: undefined;
const address = body.address !== undefined ? String(body.address).trim() : undefined;
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
const benefitUsageRuleRaw =
@@ -627,8 +653,8 @@ export class StoreService {
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
throw new BadRequestException('联系电话须为11位手机号');
if (contactPhone !== undefined && !isStoreContactPhone(contactPhone)) {
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
}
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
@@ -650,7 +676,7 @@ export class StoreService {
where: { id: storeId },
data: {
...(name !== undefined ? { name } : {}),
...(phone !== undefined ? { phone } : {}),
...(contactPhone !== undefined ? { contactPhone } : {}),
...(address !== undefined
? hasCoordsUpdate
? { address }
+16
View File
@@ -21,6 +21,7 @@
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
---
@@ -93,6 +94,21 @@
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 |
### 3.12 门店登录号与体验增量(v3.4.16)
| 字段 | 含义 | 用途 |
|------|------|------|
| `phone` / 门店登录手机号 | 老板主账号 | 门店端短信登录;`StoreAccount.phone` |
| `contactPhone` / 联系电话 | 店长等对外号码 | C 端门店详情拨号与展示 |
- 合伙人拓店入驻、门店资料修改均须采集 **联系电话**(可与登录号不同)。
- 未填联系电话时,对外展示回退登录手机号(兼容旧数据)。
- **不**再因客户端 `validation_error` 自动创建技术支持工单。
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
- HQ 门店列表:长店名截断、操作列右固定、表过宽可横向滚动。
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)。
---
+6 -7
View File
@@ -1,4 +1,4 @@
# 杜康好客 · V3.0 现状对照
# 杜康好客 · V3.0 现状对照
> 基准:仅 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · 总部 = **`admin-web`**(非 H5
> 图例:✅ 完成 · 🔶 部分 · ❌ 未做 · ⚠️ 曾冲突已修
@@ -9,7 +9,7 @@
|------|------|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
| C 端 | `mini-user` 小程序 + h5-userv3.4.13 版本门控/门店/物流已上 |
| 近期版本 | v3.4.10 套餐 · v3.4.11 开发计划/企微 · v3.4.12 工单迭代 · v3.4.13 体验优化 **已发生产** |
| 近期版本 | … · v3.4.15 · **v3.4.16 联系电话/套餐 UI/合伙人列表与入驻客服门槛/HQ 门店列表表格** |
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
@@ -55,17 +55,16 @@
| 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ |
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
## 5. 变更记录
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态
| 日期 | 说明 |
|------|------|
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
| 2026-08-07 | v3.4.15 增补:HQ 门店照片替换/删除、套餐多图上限 20 |
| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
| 2026-08-05 | v3.4.13 |
| 2026-08-04 | v3.4.11 / v3.4.12 |
| 2026-07-11 | 首版对照表 |
@@ -0,0 +1,83 @@
# 杜康好客 · v3.4.16 门店联系电话与体验优化
> **2026-08-09** · PRD §3.12 · mini-user / h5-partner / admin-web / API
## 范围
| 项 | 交付 |
|----|------|
| A. 登录号 / 联系电话分离 | `Store.contactPhone`;合伙人入驻/编辑;HQC 端 `publicDial` |
| B. 取消自动技术支持工单 | `validation_error` 不再自动建 ST;上报与人工 ST 保留 |
| C. 套餐详情排版 | 套餐名 + 价格:一行能放下则同行,否则名称换行、价格次行右对齐 |
| D. 门店详情套餐列表 | 套餐名称后展示价格 |
| E. 合伙人门店列表 | 右上角状态;开关营业/临时闭店;永久闭店按钮;下拉筛选 |
| F. 入驻企微客服门槛 | HQ 可配二维码;勾选「已添加客服」后才可提交 |
| G. HQ 门店列表表格 | 店名过长截断;操作列右侧固定;横向滚动 |
## A. 登录号 ≠ 对外联系电话
| 字段 | 含义 | 用途 |
|------|------|------|
| `phone` | 老板主账号 | 门店端登录;`StoreAccount.phone` |
| `contactPhone` | 店长/对外 | C 端拨号与展示 |
- C 端 `GET /stores``GET /stores/:id``phone` = `contactPhone ?? phone`(兼容旧小程序)
- 未填联系电话时对外回退登录号
## B. 取消 validation_error 自动 ST
- 原路径:`POST /common/client-errors` + `category=validation_error``createFromClientValidation`
- **删除**自动建单;保留 client-error 落库 / WeCom 告警;HQ 手动建单与企微「创建」不变
## C / D. mini-user 套餐 UI
- 套餐详情标题与门店详情「门店套餐」列表一致:名称在左可换行,价格始终居右;放不下时价格落在次行右侧
## E. 合伙人门店列表
| UI | 行为 |
|----|------|
| 右上角徽标 | 待审核 / 审核驳回 / 营业中 / 临时闭店 / 永久闭店 |
| 开关 | `OPEN``PAUSED`CLOSED / PENDING 禁用 |
| 永久闭店 | 独立按钮 + 确认 → `CLOSED` |
| 筛选 | 下拉:全部 / 营业中 / 临时闭店 / 待审核 / 已驳回 / 永久闭店 |
API`PUT /partner/stores/:id/status`(不变)
## F. 入驻企微客服二维码门槛
| 配置键 | 说明 |
|--------|------|
| `PARTNER_ONBOARD_CS_QR_URL` | 企微客服二维码图片 URL(OSS) |
| `PARTNER_ONBOARD_CS_HINT` | 提示文案(默认见下) |
默认提示:`使用问题、提现问题等随时可联系【杜康好客】客服`
- `GET /common/client-config` 对 PARTNER 下发 `partnerOnboardCsQrUrl` / `partnerOnboardCsHint`
- HQ **系统设置 → 微信小程序配置**(与「总部客服电话」同组):可上传二维码、编辑提示文案
- 入驻信息填完后展示二维码;须勾选「我已添加【杜康好客】客服」才可提交/跳过
- **未配置二维码**:禁止提交,提示联系总部
## G. HQ 门店列表页表格体验
[`apps/admin-web` 门店列表](apps/admin-web/src/pages/StoresPage.tsx)
| 点 | 行为 |
|----|------|
| 门店名 | 过长省略号截断,悬停可看全名;「测试」标签保留 |
| 操作列 | `fixed: 'right'`,横向滚动时详情/评价始终可见 |
| 整表过宽 | `scroll.x` 开启横向滚动条(列宽合计约 1720) |
合伙人名、介绍、分类等长文本列同样启用 ellipsis,避免撑破布局。
## ACC
- [ ] 旧门店对外拨号仍可用(回退登录号或已回填 contactPhone
- [ ] 合伙人可分别维护登录号与联系电话
- [ ] API 400 不再产生「客户端验证错误自动上报」ST
- [ ] 套餐详情短/长标题排版符合定案
- [ ] 门店详情套餐列表可见价格
- [ ] 合伙人列表状态/开关/永久闭店/下拉筛选可用
- [ ] 无二维码不可提交;有二维码须勾选后提交
- [ ] HQ 可上传/更换入驻客服二维码
- [ ] HQ 门店列表:长店名截断、操作栏右固定、可横向滚动
@@ -0,0 +1,87 @@
# 杜康好客 · v3.4.17 门店套餐审核快捷入口
> **2026-08-16** · PRD §套餐变更审核 · admin-web / API(ops)
> 目标:把「套餐变更审核」能力前置到**门店详情-套餐页签**与**门店列表操作栏**,让总部在不离开列表/详情流的情况下即可发现待审套餐并一键进入审核(含线上 vs 待审对照)。
## 范围
| 项 | 交付 |
|----|------|
| A. 门店详情·套餐页签 待审提醒 | 存在 `status=PENDING` 的套餐变更时,页签顶部黄色 Alert + 「审核 / 对比」按钮 |
| B. 审核 / 对比 跳转 | 复用现有 `StorePackageAuditsPage`,跳转 `/store-package-audits?requestId=xxx` 自动打开审核抽屉(抽屉内已含线上 vs 待审并排对照) |
| C. 门店列表 操作栏快捷入口 | 有待审套餐时显示「审核套餐」「对比」两个按钮,跳转同一地址(与详情页审核功能一致) |
| D. 列表接口补 `pendingPackageAuditId` | `GET /admin/stores` 返回每家店当前待审套餐变更的 `requestId`(一次批量查询,无 schema 变更) |
| E. 审核后提醒自动刷新 | 审核完成 dispatch `admin:package-audit-changed`,套餐页签监听后刷新提醒 |
## A / B. 门店详情·套餐页签
组件:`apps/admin-web/src/components/AdminStorePackagesSection.tsx`
- 现有 `GET /admin/stores/:storeId/packages` 已返回 `pendingRequest`(含 `id`/`status`/`packages`),此前被组件丢弃;本次**捕获 `pendingRequest`** 存入 state。
- 当 `pendingRequest.status === 'PENDING'`
- 页签顶部渲染 `<Alert type="warning">`:「该门店有待审核套餐」。
- 提醒内放「审核」「对比」两个按钮,均 `navigate('/store-package-audits?requestId=' + pendingRequest.id)`
- 空套餐门店同样展示该提醒(不依赖 `live` 是否有数据)。
- 跳转后由 `StorePackageAuditsPage` 读取 `?requestId=` 自动打开抽屉:抽屉内左侧「线上已审核套餐」、右侧「待审核套餐」并排 `diffPackages` 对照;抽屉右上「通过 / 驳回」即审核动作。两套按钮语义:审核=处置,对比=看差异,落点同一页面。
## C. 门店列表·操作栏
组件:`apps/admin-web/src/pages/StoresPage.tsx`
- `StoreRow` 增加 `pendingPackageAuditId?: string | null`
- 操作列(原「详情 / 评价」)在 `pendingPackageAuditId` 存在时追加「审核套餐」「对比」按钮,均 `navigate('/store-package-audits?requestId=' + row.pendingPackageAuditId)`
- 操作列宽度 140 → 220,允许换行,避免按钮被挤压。
## D. 列表接口补 `pendingPackageAuditId`
后端:`server/dukang-api/src/modules/ops/admin-stores.service.ts``listStores`
- 取回 `items` 后,用一次查询批量取出待审变更:
`storePackageChangeRequest.findMany({ where: { storeId: { in: storeIds }, status: 'PENDING' }, select: { id, storeId } })`
- 建 `storeId -> requestId` 映射,逐店附加 `pendingPackageAuditId`(无则 `null`)。
- `mapStoreCompat``{ ...store }` 展开透传,新字段不会被丢弃;**无 Prisma schema 变更**,发布可 `--skip-db`
## E. 审核后提醒自动刷新
- `StorePackageAuditsPage` 审核成功时调用 `notifyPackageAuditChanged()`dispatch `admin:package-audit-changed`)。
- `AdminStorePackagesSection` 监听该事件,重新拉取 `pendingRequest` 并刷新提醒(仅更新 `pendingRequest`,不触碰正在编辑的 `items`,避免覆盖未保存套餐)。
## 关键接口 / 文件
| 位置 | 说明 |
|------|------|
| `GET /admin/stores` | 列表,新增 `pendingPackageAuditId`ops/admin/* 透传,无 schema 变更) |
| `GET /admin/stores/:storeId/packages` | 返回 `pendingRequest`store-package.service.ts,未改,仅前端消费) |
| `GET /admin/store-package-audits/:requestId` | 审核详情(含 `livePackages` 对照) |
| `PUT /admin/store-package-audits/:requestId/audit` | 通过 / 驳回 |
| `apps/admin-web/src/components/AdminStorePackagesSection.tsx` | 套餐页签提醒 + 按钮 |
| `apps/admin-web/src/pages/StorePackageAuditsPage.tsx` | `?requestId=` 自动开抽屉 + 变更明细(含 `fieldChanges` / `diffText` / `TextDiff` |
| `apps/admin-web/src/pages/StoresPage.tsx` | 列表操作栏快捷入口 |
## F. 套餐变更对比·逐字段 / 逐字明细
组件:`apps/admin-web/src/pages/StorePackageAuditsPage.tsx`
> 需求:审核抽屉原本只给每条套餐「新增 / 删除 / 变更 / 未变」粗粒度标记,看不出具体哪里变了。本次在「待审核套餐」卡片底部新增**变更明细**块,逐字段列出差异;文本类字段进一步做**逐字(LCS)差异定位**,精确高亮哪些字被增 / 删。
- `fieldChanges(live, proposed)`:逐字段比对,产出 `{ label, old, now, kind }` 明细:
- `kind: 'value'`(整体替换展示 `旧 → 新`):**价格**、**图片(N 张 → M 张)**。
- `kind: 'text'`(走逐字差异):**套餐名称、菜品内容、可用时间、其他说明**。
- `diffText(a, b)`LCS 动态规划(`dp[i][j]`+ 回溯,产出 `equal / delete / insert` 段落并合并相邻同类型;时间/空间 `O(|a|·|b|)`,套餐字段长度可控,无性能风险。
- `TextDiff({ oldText, newText })`:渲染两行——
- 「原:」行用**红色删除线**标出被删的字(`delete` 段),其余正常;
- 「新:」行用**绿色**标出新增的字(`insert` 段),其余正常。
- 一眼定位到具体改动的字,而非整段替换。
- 抽屉顶部汇总条:**新增 X · 删除 Y · 变更 Z · 未变 W**(由 `diffPackages``changes` 统计)。
### 已知局限
- 套餐按**名称**(空则按位置)配对;若某条套餐**改名**,会误判为「删除旧名 + 新增新名」而非「变更」,改名本身不会进入逐字明细。如需把改名也识别为「变更」,需升级 `packageKey` 配对策略(名称变了但其余字段相近 → 视为变更)。
## 验收
- [ ] 某门店有待审套餐变更时:门店详情-套餐页签顶部出现黄色「有待审核套餐」提醒,且「审核 / 对比」可点。
- [ ] 点「审核」或「对比」均跳到套餐审核页并自动打开该门店变更抽屉,可见线上 vs 待审对照与通过/驳回。
- [ ] 门店列表操作栏对该门店出现「审核套餐」「对比」按钮,点击同样跳转并自动打开抽屉。
- [ ] 在审核页完成审核后,返回门店详情-套餐页签,提醒消失(或被事件即时刷新)。
- [ ] 无待审套餐的门店:列表与详情页签均不出现上述入口。
- [ ] 抽屉中「变更」套餐的「待审核」卡片底部出现「变更明细」:价格/图片以 `旧 → 新` 展示;菜品内容/可用时间/其他说明/套餐名称以**逐字差异**展示(原行红删、新行绿增),能精确定位到具体改动的字。
@@ -0,0 +1,128 @@
# 杜康好客 · v3.4.18 门店体验与登录态优化
> **2026-08-17** · mini-user `3.4.18` / h5-partner / h5-shop / API
> 目标:用户小程序端门店电话与套餐图体验优化;城市合伙人「暂停」账号禁止登录;门店端结算页留白对齐与「休息中能否开张核销」交互。
## 范围
| 项 | 交付 |
|----|------|
| A. mini-user 门店座机电话脱敏 | `maskPhone` 统一「中间四位隐藏」;门店详情已调用 |
| B. mini-user 套餐详情图 1/5 + 自动轮播 | `ProductCarousel` detail 变体 autoplay + 右下角 `1/5` 计数 |
| C. 合伙人账号暂停禁止登录(双拦截) | 账号 `status=DISABLED` 或主账号 `bindingStatus=PAUSED` → 登录/发码/微信均拒,提示「该账号已暂停使用」或「该合伙人合作已暂停」 |
| D. 门店端 申请提现 / 筛选栏留白对齐 | `WithdrawPage` 左右内边距统一 `--space-page` |
| E. 门店端 门店休息中 → 是否开启营业 | 门店 PAUSED 时点击首页「扫码核销」即弹「是否开启营业?」,不进入扫码流程 |
| F. mini-user 门店套餐图片自适应完整显示 | `ProductCarousel` 新增 `imageFit="adaptive"`(widthFix + 动态高度),门店套餐详情不再裁剪 |
## A. mini-user 门店座机电话脱敏(中间四位隐藏)
门店对外电话 `store.phone`= `contactPhone ?? loginPhone`,见 `server/.../common/compat/v31-compat.ts``resolveStoreContactPhone`)在门店详情以「电话: {maskPhone(...)}」展示(`apps/mini-user/src/pages/store-detail/index.tsx` ~L393-402)。
- 脱敏规则(座机):`区号 + 本地号前 2 位 + **** + 本地号后 2 位`,隐藏本地号中间四位。
- `0379-12345678``0379-12****78`
- `010-87654321``010-87****21`
- 无分机同理;手机号仍按 `138****8000` 不变。
- 实现点:`apps/mini-user/src/lib/phone.ts``maskPhone` 座机分支(当前保留末 2~4 位)→ 改为保留首 2 + 末 2、中间以 `****` 替代。
- 拨号仍走 `toDialablePhone`(明文),不受脱敏影响。
- 无 Prisma / API 变更;仅前端 `maskPhone` 规则调整。
## B. mini-user 套餐详情图 1/5 计数 + 自动轮播
入口:`apps/mini-user/src/pages/store-package-detail/index.tsx` 渲染
`<ProductCarousel images={imageUrls} variant="detail" previewable />`,图片来自 `GET /stores/:id``packages[index].imageUrls`(最多 20 张,由 `normalizeStorePackageImageUrls` 归一化)。
- 组件:`apps/mini-user/src/components/ProductCarousel.tsx`
- `variant='detail'` 时给 `<Swiper>` 增加 `autoplay` + `interval`(建议 3500ms),仅 `slides.length > 1` 时生效(现有 `circular` 已满足)。
- 右下角叠加分页计数:`${activeIndex + 1}/${slides.length}`(白底圆角胶囊,绝对定位于 `.detail-carousel-wrap` 右下角);保留原居中圆点(dots)或改为以右下计数为主。
- 单图(`slides.length === 1`)不展示计数、不开轮播。
- 图片容器 `.store-package-detail-gallery` 预留右下角定位锚点(如需)。
- 无 API / schema 变更。
## C. 合伙人账号暂停禁止登录(双拦截:账号停用 + 合伙人绑定暂停)
> 两个状态字段、两个枚举,**不要混**:
> | 维度 | 字段 | 枚举 | 取值 |
> |------|------|------|------|
> | 登录账号启用状态 | `partner_account.status` | `AccountStatus` | `ACTIVE` / `DISABLED` |
> | 城市合伙人绑定状态 | `partner_account.bindingStatus` | `CityPartnerStatus` | `ACTIVE` / `PAUSED` |
> 列表(`admin/partners`)里能看到的是 `bindingStatus`;主账号自身 `status` 不在列表返回体(仅子账号 `children[].status` 有)。
- 拦截规则(**主账号、子账号都拦**):
1. 登录账号自身 `status !== 'ACTIVE'``DISABLED`,主账号 / 子账号均适用)→ 抛 `该账号已暂停使用,请联系客服人员`
2. 所属**主账号** `bindingStatus !== 'ACTIVE'``PAUSED`,城市合伙人绑定暂停)→ 抛 `该合伙人合作已暂停,请联系客服人员`
- 子账号的绑定状态以其**父主账号**为准(`resolvePrimaryAccount`)。
- 后端统一闸门:新增 `assertPartnerAccountActive(account)``auth.service.ts` ~L307 后),一次性校验以上两条;由以下入口复用:
- `assertPartnerAccountByPhone`~L320):被 `checkPartnerPhone``POST /partner/auth/phone/check`)、发码预检 `assertSmsSendAllowed`PARTNER_LOGIN / PARTNER_PROXY_ORDER 场景)调用 → 暂停账号在「手机号校验」阶段即被拦截,无法获取短信验证码。
- `loginPartner``POST /partner/auth/login/sms`~L1063 后)。
- `loginPartnerWechat``POST /partner/auth/login/wechat`~L1726 后)。
- 前端 `apps/h5-partner/src/pages/LoginPage.tsx`
- `formatPartnerError`~L69/ `formatWechatError`(~L84):命中「已暂停 / 已停用」分支映射 `该账号已暂停使用,请联系客服人员`;其余未匹配错误(含 `该合伙人合作已暂停…`)原样透出 `return text`
- 枚举现状:`AccountStatus``schema.prisma` ~L244)与 `CityPartnerStatus`(~L203)均为既有,本次**无任何 Prisma 迁移**。
- 统一文案:
- 账号停用:`该账号已暂停使用,请联系客服人员`
- 合伙人绑定暂停:`该合伙人合作已暂停,请联系客服人员`
## D. 门店端 申请提现 / 筛选栏左右留白对齐
页面:`apps/h5-shop/src/pages/WithdrawPage.tsx`(门店管理 / 结算提现)。
- 申请提现按钮 `.shop-withdraw-btn`~L135):当前 `width:100%; margin-top:12px`,置于 `.shop-records-main`(无左右 padding)内,贴边满宽。
- 下方筛选栏 `.shop-records-filters`~L147`position: sticky; top:0`)与 `.shop-records-status-chips`:当前左右 `padding:0`chips 贴屏幕边缘。
- 同页 `.shop-records-summary` / `.shop-records-list` / `.shop-records-list-head` 均使用页面级 token `var(--space-page)` 左右内缩。
- 改动:`.shop-records-filters` 增加 `padding: 0 var(--space-page)`(sticky 背景保留);申请提现按钮区域同样左右内缩 `var(--space-page)`(或其父容器加 `padding: 0 var(--space-page)`),使其与上下组件留白一致。仅样式调整,无逻辑 / API 变更。
## E. 门店端 休息中核销 → 是否开启营业
现状:`PhoneRedeemPage.tsx` / `RedeemConfirmPage.tsx` 拉取 `GET /shop/store``status !== 'OPEN'``storeClosed=true`,核销前拦截并报「门店未营业,无法核销」(服务端 `server/.../modules/redeem/redeem.service.ts``loadOpenStoreAccount` ~L117 亦硬校验「门店未营业」)。
- **前置拦截(主路径 · 门店端首页 `HomePage`**:门店状态非 `OPEN`(PAUSED 临时闭店 / 休息中)时,用户点击首页「扫码核销」按钮**立即**弹确认框「门店目前休息中无法核销,是否开启营业?」,**不进入扫码流程**:
- 确认 → 调用 `PUT /shop/store/status { status: 'OPEN' }`,成功后关闭弹窗并 `loadDashboard()` 刷新门店状态为营业中,用户可再次点击扫码。
- 取消 → 关闭弹窗,维持拦截。
- **确认页兜底(次路径 · `RedeemConfirmPage`**:若直接带核销码进入确认页且门店仍非 `OPEN`,点击「确认核销」时同样弹「是否开启营业?」,开张后重新拉取预览并继续核销(`doConfirm`)。
- 边界:门店 `auditStatus !== 'APPROVED'``updateShopStatus` 会拒绝开张(抛「门店尚在总部审核中 / 审核未通过」),前端需捕获并提示该错误,不进入误开启。
- 适用页:`HomePage`(门店端首页扫码入口,PAUSED 时点击即弹窗)、`RedeemConfirmPage`(扫码核销确认页兜底)、`PhoneRedeemPage`(手机号核销)。
- 后端无需改动(开张接口与硬校验已存在)。
## 关键接口 / 文件
| 位置 | 说明 |
|------|------|
| `apps/mini-user/src/lib/phone.ts` `maskPhone` | 座机脱敏规则改为「中间四位隐藏」 |
| `apps/mini-user/src/pages/store-detail/index.tsx` | 门店详情电话展示(已用 maskPhone) |
| `apps/mini-user/src/components/ProductCarousel.tsx` | detail 变体 autoplay + 右下 `1/5` 计数 |
| `apps/mini-user/src/pages/store-package-detail/index.tsx` | 套餐详情图渲染 |
| `GET /stores/:id` | 门店 / 套餐数据(无变更) |
| `server/.../modules/iam/auth.service.ts` `loginPartner` / `assertPartnerAccountByPhone` / `assertSmsSendAllowed` | 合伙人登录 / 发码非 ACTIVE 抛「该账号已暂停使用,请联系客服人员」 |
| `apps/h5-partner/src/pages/LoginPage.tsx` `formatPartnerError` / `formatWechatError` | 暂停文案 |
| `apps/h5-shop/src/pages/WithdrawPage.tsx` + `styles.css` `.shop-records-filters` / `.shop-records-status-chips` / `.shop-withdraw-btn` | 左右留白对齐 `--space-page` |
| `apps/h5-shop/src/pages/PhoneRedeemPage.tsx` / `RedeemConfirmPage.tsx` | 休息中弹「是否开启营业?」 |
| `apps/h5-shop/src/pages/StatusPage.tsx` `requestToggle` / `confirmToggle` | 复用开张调用 |
| `PUT /shop/store/status` `GET /shop/store` | 切换营业 / 读取门店(已存在) |
## F. mini-user 门店套餐图片自适应完整显示(不裁剪)
门店套餐详情(`apps/mini-user/src/pages/store-package-detail/index.tsx`)的 `ProductCarousel` 此前用 `variant="detail"` 默认 `imageFit="cover"`= `aspectFill`),配合 `.detail-carousel-wrap` 的固定 `aspect-ratio: 1``overflow:hidden`,非正方形图片被裁切 → 用户反馈「图片没显示全」。
- 新增 `imageFit="adaptive"` 模式(仅作用于门店套餐详情):
- `Image` 改用 `mode="widthFix"`,按图片真实比例缩放、完整显示、不裁剪。
- 组件挂载时实测容器宽度(`Taro.createSelectorQuery().select('.detail-carousel-wrap--adaptive').boundingClientRect`),图片 `onLoad` 拿到自然宽高后按 `容器宽 × (自然高/自然宽)` 计算每张幻灯片渲染高度,赋给 `<Swiper>` 内联 `height`,实现轮播高度自适应(多图比例不一也能逐张适配,带 0.2s 过渡)。
- 未加载前兜底高度为 `容器宽 × 0.75`(约 3:4)。
- 样式:`product-detail.css` 增加 `.detail-carousel-wrap--adaptive`,覆盖基类固定 `aspect-ratio:1``overflow:hidden``aspect-ratio:auto; overflow:visible`),并令 `.detail-carousel-image` 高度为 `auto`
- `autoplay`3.5s/ `1/5` 计数(feature B)在 adaptive 下保持不变。
- 仅门店套餐详情传 `imageFit="adaptive"`;门店头图(`variant="store"``contain`)、商品详情(`cover`)不受影响。
## 验收
- [ ] 门店详情座机显示形如 `0379-12****78`(中间四位隐藏),手机号仍 `138****8000`;拨打为明文。
- [ ] 套餐详情图多张时右下角显示 `1/5` 分页计数,并自动轮播(约 3.5s 切换);单图不计数、不轮播;点击仍可预览。
- [ ] 合伙人**账号** `status=DISABLED`(主账号或子账号)时:短信登录与微信登录均被拦截,提示「该账号已暂停使用,请联系客服人员」,无法进入。
- [ ] 合伙人**绑定** `bindingStatus=PAUSED`(主账号)时:无论用主账号还是其任一子账号登录,均被拦截,提示「该合伙人合作已暂停,请联系客服人员」。
- [ ] 以上拦截在「手机号校验(`phone/check`)」阶段即生效,暂停账号拿不到短信验证码。
- [ ] 门店管理(结算提现)页:申请提现按钮与筛选栏左右留白与其他区块一致(统一 `--space-page`),不再贴边。
- [ ] 门店休息中(PAUSED)时:点击门店端首页「扫码核销」按钮**立即**弹「门店目前休息中无法核销,是否开启营业?」,**不进入扫码流程**;确认开启后门店状态刷新、可再次扫码;取消则维持拦截;未过审门店开张被拒时给出对应提示。
- [ ] 门店套餐详情图片按真实比例完整显示、不再被裁切(轮播高度随图自适应);自动轮播与 `1/5` 计数仍正常。
- [ ] A 仅 `maskPhone` 规则、C/D/E 仅前端样式 / 交互;均无需 Prisma 迁移(C 复用 `DISABLED`)。
## HQ 开发计划
创建版本 `v3.4.18` 并关联本迭代任务;发版前再合并发布(mini-user 升 `3.4.18`)。