Compare commits
96 Commits
75d820b574
...
v3.4.18
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fb7ab7abb | |||
| 905e145af3 | |||
| 7dd5fdfb12 | |||
| 48d6900ab6 | |||
| 5ca9aa00aa | |||
| c0d04ee500 | |||
| 3ac008a217 | |||
| cd1e9ae488 | |||
| 18147e157f | |||
| f7c94b4f16 | |||
| cc750dc1d6 | |||
| 984dec79ee | |||
| f84614bbb4 | |||
| c0ce494732 | |||
| 69e201f927 | |||
| ecd9973d8f | |||
| bbd7e52e70 | |||
| 1c91e6bae9 | |||
| 029fd690a5 | |||
| 0bfc19b1a4 | |||
| bafb342600 | |||
| bac5c7ad80 | |||
| c05cd0e96d | |||
| 8de41796fb | |||
| 4b3b1f76ae | |||
| 4a0e18472b | |||
| ceacce9ed5 | |||
| 6754caf076 | |||
| 5586ed90cd | |||
| 4488b0c006 | |||
| da6a38daa4 | |||
| d6d4a77473 | |||
| 7c6dae472c | |||
| 3b192b0b88 | |||
| 36017a19b1 | |||
| acc841bb21 | |||
| 713dce52b4 | |||
| b19f875e7b | |||
| 7298b1a5d5 | |||
| b4d6f2bd91 | |||
| d98ef6b808 | |||
| 1a615ffe93 | |||
| b5fb9a0bb2 | |||
| b32c68d56b | |||
| 7bc710f139 | |||
| 91fed31b10 | |||
| 0181af09c5 | |||
| 291137a15d | |||
| 33b868dfac | |||
| 37bc297d1a | |||
| 429eaabe6b | |||
| 456b3af461 | |||
| 69f52921a7 | |||
| 7c9240fc84 | |||
| 9d49ed5e70 | |||
| d0ab838da6 | |||
| dc4295d1bd | |||
| bf501d2374 | |||
| b40eb83f27 | |||
| f5e523a425 | |||
| b21403a47c | |||
| f9a5b6e81a | |||
| 1c787ed576 | |||
| aca3df4bf2 | |||
| fa0d928980 | |||
| 6f427cb553 | |||
| a7d55b7293 | |||
| f09e00e16f | |||
| aa0e140fec | |||
| f8d8e2d6d5 | |||
| 9859abba7e | |||
| c89560e4f8 | |||
| ffed560d43 | |||
| 5199b495e0 | |||
| 82b98aa4e3 | |||
| 7dffd40f3f | |||
| 0304201e80 | |||
| dd4362c397 | |||
| 87515decb3 | |||
| 28ef916a22 | |||
| 39e38aac7e | |||
| daf534b4db | |||
| 118993a01f | |||
| 8f361981bf | |||
| 4bb6ae20cd | |||
| ad757e9ba8 | |||
| e63b57db19 | |||
| fb4432b307 | |||
| ab44515e8b | |||
| 78365d648c | |||
| e9eddbb26e | |||
| c00ca3620d | |||
| 4d76ee6d0e | |||
| 2dbe665bdc | |||
| 1bc3bec977 | |||
| 81a6e3674b |
@@ -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 上述工具;create(645 行)/ update(451 行)路径由单条 `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 客户端。
|
||||
@@ -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/config:HostName 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'` 类型无重叠),与本改动无关,未动。
|
||||
@@ -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 数量=0;binding_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,9 +1,11 @@
|
||||
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;
|
||||
@@ -32,8 +34,10 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
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;
|
||||
@@ -45,8 +49,10 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
|
||||
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) => {
|
||||
@@ -66,6 +72,41 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
.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)));
|
||||
}
|
||||
@@ -200,6 +241,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
return (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
||||
{pendingReminder}
|
||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||
添加套餐
|
||||
</Button>
|
||||
@@ -212,6 +254,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
|
||||
return (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
{pendingReminder}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
@@ -283,7 +326,8 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
</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 })}
|
||||
@@ -303,7 +347,8 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
||||
<Input
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
|
||||
@@ -258,9 +258,10 @@ function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number
|
||||
return {
|
||||
...item,
|
||||
label: (
|
||||
<Badge count={pendingCount} size="small" offset={[8, 0]}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
套餐审核
|
||||
</Badge>
|
||||
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
||||
</span>
|
||||
),
|
||||
} as MenuItem;
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -41,6 +42,71 @@ 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]));
|
||||
@@ -50,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) {
|
||||
@@ -66,7 +133,13 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
|
||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||
imageSignature(l) !== imageSignature(p);
|
||||
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
|
||||
rows.push({
|
||||
key,
|
||||
change: changed ? 'changed' : 'unchanged',
|
||||
live: l,
|
||||
proposed: p,
|
||||
changes: changed ? fieldChanges(l, p) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
@@ -79,14 +152,53 @@ 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;
|
||||
@@ -148,6 +260,39 @@ function PackageDetailCard({
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -193,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);
|
||||
@@ -227,6 +380,11 @@ export default function StorePackageAuditsPage() {
|
||||
|
||||
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||
|
||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
@@ -345,9 +503,17 @@ export default function StorePackageAuditsPage() {
|
||||
{detail.rejectReason ? (
|
||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||
) : null}
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||
</Typography.Paragraph>
|
||||
<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 }}>
|
||||
@@ -377,6 +543,7 @@ export default function StorePackageAuditsPage() {
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
changes={changesByKey.get(packageKey(pkg, index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -190,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 };
|
||||
@@ -765,12 +767,30 @@ export default function StoresPage() {
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -119,15 +119,13 @@ 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
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
@@ -154,15 +152,13 @@ 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
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||
|
||||
@@ -285,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,34 +50,37 @@ 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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(async (e) => {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
const report = await reportRedeemFailure(token, 'preview', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
setShowWeakNet(true);
|
||||
} else if (report) {
|
||||
setFailCount(report.failCount);
|
||||
}
|
||||
try {
|
||||
const p = await request<Preview>('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
setPreview(p);
|
||||
setMsg('');
|
||||
} catch (e) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
const report = await reportRedeemFailure(token, 'preview', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
setShowWeakNet(true);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,8 +19,46 @@ export function validateMobilePhone(phone: string): { ok: boolean; message?: str
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function normalizeContactPhone(raw: string): string {
|
||||
return String(raw ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏展示:手机 138****8000;座机隐藏本地号中间四位,如 0379-12****78。
|
||||
* 门店详情电话展示用(拨号仍走 toDialablePhone 明文)。
|
||||
*/
|
||||
export function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
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 {
|
||||
|
||||
@@ -143,6 +143,7 @@ export default function StorePackageDetailPage() {
|
||||
alt={pkg.name}
|
||||
variant="detail"
|
||||
previewable
|
||||
imageFit="adaptive"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
# - 本地已安装 docker(Windows 用 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 已追平线上 schema(pnpm 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 的副本。"
|
||||
@@ -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'
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isLandlinePhone,
|
||||
isMobilePhone,
|
||||
isStoreContactPhone,
|
||||
maskContactPhone,
|
||||
normalizeContactPhone,
|
||||
toDialablePhone,
|
||||
} from './phone';
|
||||
@@ -36,4 +37,13 @@ describe('store contact phone', () => {
|
||||
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('—');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,3 +33,39 @@ export function isStoreContactPhone(raw: string): boolean {
|
||||
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 '****';
|
||||
}
|
||||
|
||||
@@ -304,12 +304,34 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合伙人账号可登录态校验(统一闸门):
|
||||
* 1) 账号自身 status !== ACTIVE(DISABLED,含子账号)→ 拦截
|
||||
* 2) 所属主账号 bindingStatus !== ACTIVE(PAUSED,城市合伙人绑定暂停)→ 拦截
|
||||
* 子账号的绑定状态以父主账号为准。
|
||||
*/
|
||||
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 },
|
||||
|
||||
@@ -93,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;
|
||||
@@ -100,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,
|
||||
|
||||
@@ -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`)。
|
||||
Reference in New Issue
Block a user