16 Commits

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 18:45:10 +08:00
37 changed files with 1121 additions and 201 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'` 类型无重叠),与本改动无关,未动。
+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,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 })}
+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));
}
+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)}`;
+7
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;
@@ -59,6 +63,7 @@ export function validateStoreCreateStep1(
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'contactPhone'
>,
): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人';
@@ -68,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,5 +1,7 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Badge,
Button,
Drawer,
Image,
@@ -14,17 +16,23 @@ 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,
normalizeStorePackageImageUrls,
} 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}`;
@@ -34,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]));
@@ -43,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) {
@@ -59,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;
@@ -72,14 +152,56 @@ 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={{
@@ -90,27 +212,36 @@ function PackageDetailCard({
background: '#fafafa',
}}
>
{title ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{title}
</Typography.Text>
) : null}
<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 style={{ marginBottom: 8, whiteSpace: 'pre-wrap' }}>
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<div style={{ marginBottom: 4 }}>
<Typography.Text type="secondary">{pkg.usableTime}</Typography.Text>
</div>
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 4 }}
>
{pkg.usableTime}
</Typography.Paragraph>
) : null}
{pkg.otherNotes ? (
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary">{pkg.otherNotes}</Typography.Text>
</div>
<Typography.Paragraph
type="secondary"
className="admin-package-audit-text"
style={{ marginBottom: 8 }}
>
{pkg.otherNotes}
</Typography.Paragraph>
) : null}
{images.length ? (
<Image.PreviewGroup>
@@ -129,46 +260,38 @@ function PackageDetailCard({
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
);
}
function PackageSummaryCell({ pkg }: { pkg?: StorePackageItemDto | StorePackageViewDto }) {
if (!pkg) return <></>;
const images = normalizeStorePackageImageUrls(pkg);
return (
<div>
<div>
<strong>{pkg.name}</strong> · ¥{pkg.price}
</div>
<Typography.Text type="secondary">{pkg.dishes}</Typography.Text>
{pkg.usableTime ? (
<div>
<Typography.Text type="secondary">{pkg.usableTime}</Typography.Text>
</div>
) : null}
{pkg.otherNotes ? (
<div>
<Typography.Text type="secondary">{pkg.otherNotes}</Typography.Text>
</div>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={4} style={{ marginTop: 8 }}>
{images.slice(0, 4).map((url) => (
<Image
key={url}
src={url}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
{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>
))}
{images.length > 4 ? (
<Typography.Text type="secondary">+{images.length - 4}</Typography.Text>
) : null}
</Space>
</Image.PreviewGroup>
</ul>
</div>
) : null}
</div>
);
@@ -180,17 +303,13 @@ export default function StorePackageAuditsPage() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('PENDING');
const [pendingCount, setPendingCount] = useState(0);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
const [pkgDetailOpen, setPkgDetailOpen] = useState(false);
const [pkgDetailTitle, setPkgDetailTitle] = useState('');
const [pkgDetailList, setPkgDetailList] = useState<Array<StorePackageItemDto | StorePackageViewDto>>(
[],
);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -200,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 {
@@ -217,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);
@@ -232,15 +361,6 @@ export default function StorePackageAuditsPage() {
}
}
function openPackageDetails(
title: string,
list: Array<StorePackageItemDto | StorePackageViewDto>,
) {
setPkgDetailTitle(title);
setPkgDetailList(list);
setPkgDetailOpen(true);
}
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
try {
await request(`/admin/store-package-audits/${id}/audit`, {
@@ -251,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 : '操作失败');
@@ -258,26 +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) => <PackageSummaryCell pkg={row.live} />,
},
{
title: '申请变更',
render: (_, row) => <PackageSummaryCell pkg={row.proposed} />,
},
];
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 },
@@ -285,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>
),
},
{
@@ -335,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>
@@ -380,7 +495,7 @@ export default function StorePackageAuditsPage() {
) : detail ? (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
</Typography.Text>
@@ -388,55 +503,58 @@ export default function StorePackageAuditsPage() {
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<Space style={{ marginBottom: 12 }} wrap>
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
<Typography.Text type="secondary">
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0}
线 {detail.livePackages?.length ?? 0} · {detail.packages?.length ?? 0}
</Typography.Text>
<Button
size="small"
onClick={() =>
openPackageDetails('申请套餐详情', detail.packages ?? [])
}
>
</Button>
<Button
size="small"
onClick={() =>
openPackageDetails('线上套餐详情', detail.livePackages ?? [])
}
>
线
</Button>
<Space wrap>
<Tag color="green"> {addedCount}</Tag>
<Tag color="red"> {removedCount}</Tag>
<Tag color="orange"> {changedCount}</Tag>
{unchangedCount ? <Tag> {unchangedCount}</Tag> : null}
</Space>
</Space>
<Table
size="small"
rowKey="key"
columns={diffColumns}
dataSource={diffRows}
pagination={false}
/>
<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>
<Modal
title={pkgDetailTitle || '套餐详情'}
open={pkgDetailOpen}
onCancel={() => setPkgDetailOpen(false)}
footer={null}
width={720}
destroyOnClose
>
{pkgDetailList.length ? (
pkgDetailList.map((pkg, index) => (
<PackageDetailCard key={`${pkg.name}-${index}`} title={`套餐 ${index + 1}`} pkg={pkg} />
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Modal>
<Modal
title="驳回套餐变更"
open={rejectOpen}
+45 -7
View File
@@ -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,
@@ -189,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 };
@@ -764,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>
),
},
@@ -966,10 +987,18 @@ export default function StoresPage() {
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
rules={[{ required: true, message: '请填写对外联系电话' }]}
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同"
rules={[
{ required: true, message: '请填写对外联系电话' },
{
validator: (_, value) =>
isStoreContactPhone(String(value || ''))
? Promise.resolve()
: Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)),
},
]}
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机"
>
<Input placeholder="11位手机号" />
<Input placeholder="手机号或座机,如 0379-8888888" />
</Form.Item>
<Form.Item
name="categoryParentId"
@@ -1252,9 +1281,18 @@ export default function StoresPage() {
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
extra="用户端拨号展示;留空则与登录号相同"
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="11位手机号,可与登录号不同" />
<Input placeholder="手机号或座机,如 0379-8888888" />
</Form.Item>
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
+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"]
}
+6
View File
@@ -1,10 +1,16 @@
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,
+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:*",
@@ -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}
+4 -3
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;
@@ -48,8 +51,6 @@ 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: '',
@@ -274,6 +275,6 @@ export function validateStoreStep3(
if (!form.phone.trim()) return '请填写门店登录手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店登录手机号须为11位手机号';
if (!form.contactPhone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.contactPhone.trim())) return '联系电话须为11位手机号';
if (!isStoreContactPhone(form.contactPhone.trim())) return STORE_CONTACT_PHONE_HINT;
return null;
}
@@ -1175,7 +1175,7 @@ export default function StoreCreatePage() {
type="tel"
placeholder="店长或对外可拨打号码"
placeholder="手机号或座机,如 0379-8888888"
value={form.contactPhone}
@@ -1193,7 +1193,7 @@ export default function StoreCreatePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}>
使
使 0379-8888888
</p>
@@ -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>
@@ -365,12 +365,13 @@ export default function StoreDetailPage() {
<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">
+24
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;
+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"]
+1
View File
@@ -9,6 +9,7 @@ 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: {
@@ -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}
+38 -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,41 @@ 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-****888。
* 门店详情电话展示用(拨号仍走 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);
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}`;
}
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 {
@@ -276,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() {
+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 '****';
}
@@ -105,6 +105,10 @@ export interface StorePackageAuditAction {
rejectReason?: string;
}
export interface StorePackageAuditSummaryDto {
pendingCount: number;
}
export interface CreatePackageDisputeRequest {
storeId: string;
remark?: string;
+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
@@ -1,6 +1,6 @@
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';
@@ -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,
@@ -277,14 +290,14 @@ export class AdminStoresService {
if (dto.phone !== undefined) {
const normalizedPhone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
if (!isMobilePhone(normalizedPhone)) {
throw new BadRequestException('请输入正确的登录手机号');
}
}
if (dto.contactPhone !== undefined) {
const contact = dto.contactPhone.trim();
if (contact && !/^1[3-9]\d{9}$/.test(contact)) {
throw new BadRequestException('请输入正确的联系电话');
if (contact && !isStoreContactPhone(contact)) {
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
}
}
@@ -505,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({
@@ -576,8 +589,8 @@ export class AdminStoresService {
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
const contactPhoneRaw = dto.contactPhone?.trim() || normalizedPhone;
if (!/^1[3-9]\d{9}$/.test(contactPhoneRaw)) {
throw new BadRequestException('请输入正确的联系电话');
if (!isStoreContactPhone(contactPhoneRaw)) {
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
}
const store = await this.prisma.store.create({
@@ -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,7 +7,7 @@ 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';
@@ -371,8 +371,8 @@ export class StoreService {
body.contactPhone != null && String(body.contactPhone).trim()
? String(body.contactPhone).trim()
: normalizedPhone;
if (!/^1[3-9]\d{9}$/.test(contactPhoneRaw)) {
throw new BadRequestException('联系电话须为11位手机号');
if (!isStoreContactPhone(contactPhoneRaw)) {
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
}
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
@@ -653,8 +653,8 @@ export class StoreService {
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
if (contactPhone !== undefined && !/^1[3-9]\d{9}$/.test(contactPhone)) {
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)) {
@@ -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 待审对照与通过/驳回。
- [ ] 门店列表操作栏对该门店出现「审核套餐」「对比」按钮,点击同样跳转并自动打开抽屉。
- [ ] 在审核页完成审核后,返回门店详情-套餐页签,提醒消失(或被事件即时刷新)。
- [ ] 无待审套餐的门店:列表与详情页签均不出现上述入口。
- [ ] 抽屉中「变更」套餐的「待审核」卡片底部出现「变更明细」:价格/图片以 `旧 → 新` 展示;菜品内容/可用时间/其他说明/套餐名称以**逐字差异**展示(原行红删、新行绿增),能精确定位到具体改动的字。