Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 984dec79ee | |||
| f84614bbb4 | |||
| c0ce494732 | |||
| 69e201f927 | |||
| ecd9973d8f | |||
| bbd7e52e70 | |||
| 1c91e6bae9 | |||
| 029fd690a5 | |||
| 0bfc19b1a4 | |||
| bafb342600 | |||
| bac5c7ad80 | |||
| c05cd0e96d | |||
| 8de41796fb | |||
| 4b3b1f76ae | |||
| 75d820b574 | |||
| 0f48d7abda | |||
| 4a0e18472b | |||
| 491f03e825 | |||
| 88053353ab | |||
| ceacce9ed5 | |||
| 3913bf3bf3 | |||
| 478291e2b3 | |||
| 6754caf076 | |||
| 92c3175acf | |||
| 46361ec713 | |||
| 5586ed90cd | |||
| 4cec5b1fe5 | |||
| 07630c9046 | |||
| 4488b0c006 | |||
| 99c74f20d2 | |||
| a598d1cd30 | |||
| da6a38daa4 | |||
| c5926bd593 | |||
| df546ff39f | |||
| d6d4a77473 | |||
| 0518976953 | |||
| 8f83624ffe | |||
| 7c6dae472c | |||
| 209db59422 | |||
| 21d44026d9 | |||
| 3b192b0b88 | |||
| 1b3a7f3a36 | |||
| 34a1897c09 | |||
| 36017a19b1 | |||
| 9c7550a1c4 | |||
| fe1c6c8158 | |||
| acc841bb21 | |||
| 61a99aeea9 | |||
| 76375eff83 | |||
| 713dce52b4 | |||
| e41bf838d4 | |||
| 0692bebf07 | |||
| b19f875e7b | |||
| 090002b6c0 | |||
| 8726cf14e3 | |||
| 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 |
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
||||||
|
|
||||||
Vite 代理:`/api` → `localhost:3000`。
|
Vite 代理:`/api` → `localhost:3010`(可用 `VITE_API_TARGET` 覆盖)。
|
||||||
|
|
||||||
## 共享包
|
## 共享包
|
||||||
|
|
||||||
|
|||||||
@@ -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'` 类型无重叠),与本改动无关,未动。
|
||||||
@@ -28,7 +28,7 @@ pnpm db:generate && pnpm db:validate
|
|||||||
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
|
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
|
||||||
|
|
||||||
# 开发(分终端)
|
# 开发(分终端)
|
||||||
pnpm dev:api # http://localhost:3000/api/v1
|
pnpm dev:api # http://localhost:3010/api/v1
|
||||||
pnpm dev:user # :5173
|
pnpm dev:user # :5173
|
||||||
pnpm dev:shop # :5174
|
pnpm dev:shop # :5174
|
||||||
pnpm dev:partner # :5175
|
pnpm dev:partner # :5175
|
||||||
@@ -144,7 +144,7 @@ C 端门店仅 status=OPEN
|
|||||||
|
|
||||||
| 环境 | 分支 | 目录 | 端口 | 域名 |
|
| 环境 | 分支 | 目录 | 端口 | 域名 |
|
||||||
|------|------|------|------|------|
|
|------|------|------|------|------|
|
||||||
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — |
|
||||||
| **staging 测试** | `dev` | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
| **staging 测试** | `dev` | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||||
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.5.1",
|
"@ant-design/icons": "^5.5.1",
|
||||||
|
"@dukang/domain": "workspace:*",
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"@dukang/shared-ui": "workspace:*",
|
"@dukang/shared-ui": "workspace:*",
|
||||||
"antd": "^5.22.0",
|
"antd": "^5.22.0",
|
||||||
|
|||||||
@@ -86,3 +86,21 @@ body,
|
|||||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-col {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,253 +1,381 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
||||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import OssUpload from './OssUpload';
|
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
|
import PackageImagesUpload from './PackageImagesUpload';
|
||||||
|
|
||||||
type PackageRow = StorePackageItemDto;
|
type PackageRow = StorePackageItemDto;
|
||||||
|
|
||||||
|
export type AdminStorePackagesHandle = {
|
||||||
|
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
|
||||||
|
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
function emptyRow(index = 0): PackageRow {
|
function emptyRow(index = 0): PackageRow {
|
||||||
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', imageUrl: '', sortOrder: index };
|
return {
|
||||||
|
name: '',
|
||||||
|
price: '0',
|
||||||
|
dishes: '',
|
||||||
|
usableTime: '',
|
||||||
|
otherNotes: '',
|
||||||
|
imageUrl: '',
|
||||||
|
imageUrls: [],
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
|
||||||
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
function AdminStorePackagesSection({ storeId }, ref) {
|
||||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||||
|
const itemsRef = useRef(items);
|
||||||
|
const loadingRef = useRef(loading);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
itemsRef.current = items;
|
||||||
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
|
}, [items]);
|
||||||
.then((data) => {
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadingRef.current = loading;
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setPendingRequest(null);
|
||||||
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
|
.then((data) => {
|
||||||
|
setPendingRequest(data.pendingRequest ?? null);
|
||||||
|
setItems(
|
||||||
|
data.live?.length
|
||||||
|
? data.live.map((p, i) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
// 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
|
||||||
|
useEffect(() => {
|
||||||
|
const onChanged = () => {
|
||||||
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
|
.then((data) => setPendingRequest(data.pendingRequest ?? null))
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
function goAudit() {
|
||||||
|
if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingReminder =
|
||||||
|
pendingRequest && pendingRequest.status === 'PENDING' ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="该门店有待审核套餐"
|
||||||
|
description={
|
||||||
|
<Space>
|
||||||
|
<Button size="small" type="primary" onClick={goAudit}>
|
||||||
|
审核
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={goAudit}>
|
||||||
|
对比
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||||
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||||
|
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
const run = () => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
||||||
|
return next.length ? next : [];
|
||||||
|
});
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next: Record<number, boolean> = {};
|
||||||
|
Object.entries(prev).forEach(([k, v]) => {
|
||||||
|
const i = Number(k);
|
||||||
|
if (i < index) next[i] = v;
|
||||||
|
else if (i > index) next[i - 1] = v;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (items.length === 1) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '清空门店套餐',
|
||||||
|
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
|
||||||
|
okText: '确认删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: run,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollapse(index: number) {
|
||||||
|
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(opts?: { quiet?: boolean }) {
|
||||||
|
const currentItems = itemsRef.current;
|
||||||
|
const filled = currentItems
|
||||||
|
.map((item, index) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(item);
|
||||||
|
return {
|
||||||
|
name: item.name.trim(),
|
||||||
|
price: item.price.trim(),
|
||||||
|
dishes: item.dishes.trim(),
|
||||||
|
usableTime: item.usableTime?.trim() || null,
|
||||||
|
otherNotes: item.otherNotes?.trim() || null,
|
||||||
|
imageUrl: imageUrls[0] ?? null,
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
|
||||||
|
.filter((item) => {
|
||||||
|
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
|
||||||
|
const hasImages = item.imageUrls.length > 0;
|
||||||
|
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
|
||||||
|
return hasText || hasImages || hasNonZeroPrice;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < filled.length; i++) {
|
||||||
|
const item = filled[i];
|
||||||
|
if (!item.name) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
if (!item.dishes) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
const price = Number(item.price);
|
||||||
|
if (!Number.isFinite(price) || price < 0) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!opts?.quiet) message.success('套餐已保存并生效');
|
||||||
setItems(
|
setItems(
|
||||||
data.live?.length
|
data.live?.length
|
||||||
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
|
? data.live.map((p, i) => {
|
||||||
: [emptyRow()],
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
})
|
} catch (e) {
|
||||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
.finally(() => setLoading(false));
|
throw e;
|
||||||
}, [storeId]);
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRow() {
|
|
||||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
|
||||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAt(index: number) {
|
|
||||||
const run = () => {
|
|
||||||
setItems((prev) => {
|
|
||||||
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
|
||||||
return next.length ? next : [];
|
|
||||||
});
|
|
||||||
setCollapsed((prev) => {
|
|
||||||
const next: Record<number, boolean> = {};
|
|
||||||
Object.entries(prev).forEach(([k, v]) => {
|
|
||||||
const i = Number(k);
|
|
||||||
if (i < index) next[i] = v;
|
|
||||||
else if (i > index) next[i - 1] = v;
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (items.length === 1) {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '清空门店套餐',
|
|
||||||
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
|
|
||||||
okText: '确认删除',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: run,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
function toggleCollapse(index: number) {
|
|
||||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const filled = items
|
|
||||||
.map((item, index) => ({
|
|
||||||
name: item.name.trim(),
|
|
||||||
price: item.price.trim(),
|
|
||||||
dishes: item.dishes.trim(),
|
|
||||||
usableTime: item.usableTime?.trim() || null,
|
|
||||||
otherNotes: item.otherNotes?.trim() || null,
|
|
||||||
imageUrl: item.imageUrl?.trim() || null,
|
|
||||||
sortOrder: index,
|
|
||||||
}))
|
|
||||||
.filter((item) => item.name || item.dishes || item.price);
|
|
||||||
|
|
||||||
for (let i = 0; i < filled.length; i++) {
|
|
||||||
const item = filled[i];
|
|
||||||
if (!item.name) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!item.dishes) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const price = Number(item.price);
|
|
||||||
if (!Number.isFinite(price) || price < 0) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
useImperativeHandle(ref, () => ({
|
||||||
try {
|
saveIfLoaded: async (opts) => {
|
||||||
await request(`/admin/stores/${storeId}/packages`, {
|
if (loadingRef.current) return { skipped: true };
|
||||||
method: 'PUT',
|
await save(opts);
|
||||||
body: JSON.stringify({
|
return { skipped: false };
|
||||||
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
},
|
||||||
}),
|
}));
|
||||||
});
|
|
||||||
message.success('套餐已保存并生效');
|
if (loading) {
|
||||||
} catch (e) {
|
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (!items.length) {
|
||||||
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
return (
|
||||||
}
|
<Form layout="vertical" requiredMark={false}>
|
||||||
|
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
||||||
|
{pendingReminder}
|
||||||
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||||
|
添加套餐
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||||
|
保存套餐
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return (
|
return (
|
||||||
<Form layout="vertical" requiredMark={false}>
|
<Form layout="vertical" requiredMark={false}>
|
||||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
{pendingReminder}
|
||||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
<Alert
|
||||||
添加套餐
|
type="info"
|
||||||
</Button>
|
showIcon
|
||||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
style={{ marginBottom: 16 }}
|
||||||
|
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isCollapsed = !!collapsed[index];
|
||||||
|
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
onClick={() => toggleCollapse(index)}
|
||||||
|
style={{ paddingLeft: 0, height: 'auto' }}
|
||||||
|
>
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography.Title>
|
||||||
|
</Button>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{!isCollapsed ? (
|
||||||
|
<>
|
||||||
|
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="如:套餐A"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonAfter="元"
|
||||||
|
placeholder="198"
|
||||||
|
value={item.price === '' ? undefined : Number(item.price)}
|
||||||
|
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||||
|
value={item.dishes}
|
||||||
|
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="节假日除外"
|
||||||
|
value={item.usableTime || ''}
|
||||||
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
||||||
|
<PackageImagesUpload
|
||||||
|
value={normalizeStorePackageImageUrls(item)}
|
||||||
|
onChange={(imageUrls) =>
|
||||||
|
updateAt(index, {
|
||||||
|
imageUrls,
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="不可叠加"
|
||||||
|
value={item.otherNotes || ''}
|
||||||
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||||
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||||
|
添加套餐
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
上传套餐图后请点右上角「保存修改」(会连同套餐一起保存),或点下方「保存套餐」。仅上传不保存,刷新会丢失。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||||
保存套餐
|
保存套餐
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return ( <Form layout="vertical" requiredMark={false}>
|
export default AdminStorePackagesSection;
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isCollapsed = !!collapsed[index];
|
|
||||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
marginBottom: 16,
|
|
||||||
padding: 16,
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: '#fafafa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
|
||||||
onClick={() => toggleCollapse(index)}
|
|
||||||
style={{ paddingLeft: 0, height: 'auto' }}
|
|
||||||
>
|
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
||||||
{displayName}
|
|
||||||
</Typography.Title>
|
|
||||||
</Button>
|
|
||||||
{items.length > 0 ? (
|
|
||||||
<Button type="link" danger onClick={() => removeAt(index)}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
) : null} </Space>
|
|
||||||
|
|
||||||
{!isCollapsed ? (
|
|
||||||
<>
|
|
||||||
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
|
||||||
<Input
|
|
||||||
placeholder="如:套餐A"
|
|
||||||
value={item.name}
|
|
||||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
precision={2}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
addonAfter="元"
|
|
||||||
placeholder="198"
|
|
||||||
value={item.price === '' ? undefined : Number(item.price)}
|
|
||||||
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
|
||||||
value={item.dishes}
|
|
||||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
|
||||||
<Input
|
|
||||||
placeholder="节假日除外"
|
|
||||||
value={item.usableTime || ''}
|
|
||||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
|
||||||
<OssUpload
|
|
||||||
bizType="STORE_PACKAGE"
|
|
||||||
mediaType="IMAGE"
|
|
||||||
value={item.imageUrl || undefined}
|
|
||||||
onChange={(url) => updateAt(index, { imageUrl: url })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="其他说明" style={{ marginBottom: 0 }}> <Input
|
|
||||||
placeholder="不可叠加"
|
|
||||||
value={item.otherNotes || ''}
|
|
||||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
|
||||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
|
||||||
添加套餐
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
|
||||||
修改后点击下方按钮保存,C 端将立即展示生效套餐。
|
|
||||||
</Typography.Paragraph>
|
|
||||||
|
|
||||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
|
||||||
保存套餐
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button, Form, Space, Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
import MultiImageUpload from './MultiImageUpload';
|
||||||
import OssUpload from './OssUpload';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -8,42 +7,35 @@ type Props = {
|
|||||||
bizType?: string;
|
bizType?: string;
|
||||||
/** 最多可添加张数;不传则不限制 */
|
/** 最多可添加张数;不传则不限制 */
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
|
/** Form.Item 注入 */
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品详情/轮播等多图列表。
|
||||||
|
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
|
||||||
|
*/
|
||||||
export default function DetailImageUrlList({
|
export default function DetailImageUrlList({
|
||||||
name = 'detailImageUrls',
|
|
||||||
label,
|
label,
|
||||||
bizType = 'DETAIL',
|
bizType = 'DETAIL',
|
||||||
maxCount,
|
maxCount,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{maxCount != null && (
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
{label}:支持批量上传{maxCount != null ? `,最多 ${maxCount} 张` : ''}
|
||||||
最多 {maxCount} 张{label}
|
</Typography.Text>
|
||||||
</Typography.Text>
|
<MultiImageUpload
|
||||||
)}
|
bizType={bizType}
|
||||||
<Form.List name={name}>
|
mediaType="IMAGE"
|
||||||
{(fields, { add, remove }) => (
|
maxCount={maxCount}
|
||||||
<>
|
value={value}
|
||||||
{fields.map((field) => (
|
onChange={onChange}
|
||||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
tip={`支持一次选择多张${label}`}
|
||||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
/>
|
||||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
|
||||||
</Form.Item>
|
|
||||||
{fields.length > 1 && (
|
|
||||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
))}
|
|
||||||
{(!maxCount || fields.length < maxCount) && (
|
|
||||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
|
||||||
添加{label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Image, Space, Typography, Upload, message } from 'antd';
|
||||||
|
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadProps } from 'antd';
|
||||||
|
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
|
bizType: string;
|
||||||
|
mediaType?: OssMediaType;
|
||||||
|
/** 最多张数;不传则不限制 */
|
||||||
|
maxCount?: number;
|
||||||
|
tip?: string;
|
||||||
|
accept?: string;
|
||||||
|
/** 上传按钮文案,默认「批量上传图片」 */
|
||||||
|
buttonText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUrls(value?: string[]) {
|
||||||
|
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(url: string) {
|
||||||
|
return /\.pdf(\?|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
|
||||||
|
* Form.Item 直接绑定 string[]。
|
||||||
|
*/
|
||||||
|
export default function MultiImageUpload({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
bizType,
|
||||||
|
mediaType = 'IMAGE',
|
||||||
|
maxCount,
|
||||||
|
tip,
|
||||||
|
accept = 'image/*',
|
||||||
|
buttonText = '批量上传图片',
|
||||||
|
}: Props) {
|
||||||
|
const urls = normalizeUrls(value);
|
||||||
|
const urlsRef = useRef(urls);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const batchBuf = useRef<File[]>([]);
|
||||||
|
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const uploadChain = useRef(Promise.resolve());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
urlsRef.current = urls;
|
||||||
|
}, [urls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (batchTimer.current) clearTimeout(batchTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
|
||||||
|
const canAdd = remaining > 0;
|
||||||
|
|
||||||
|
async function uploadBatch(files: File[]) {
|
||||||
|
const current = urlsRef.current;
|
||||||
|
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
|
||||||
|
const picked = files.slice(0, room);
|
||||||
|
if (!picked.length) {
|
||||||
|
message.warning(maxCount != null ? `最多 ${maxCount} 张` : '无法上传');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length > picked.length) {
|
||||||
|
message.warning(`已达上限,仅上传前 ${picked.length} 张`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const appended: string[] = [];
|
||||||
|
let fail = 0;
|
||||||
|
try {
|
||||||
|
for (const file of picked) {
|
||||||
|
try {
|
||||||
|
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||||
|
appended.push(result.url);
|
||||||
|
} catch {
|
||||||
|
fail += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appended.length) {
|
||||||
|
// 始终基于最新列表追加,避免并行上传互相覆盖
|
||||||
|
const next = [...urlsRef.current, ...appended];
|
||||||
|
urlsRef.current = next;
|
||||||
|
onChangeRef.current?.(next);
|
||||||
|
message.success(`成功上传 ${appended.length} 张${fail ? `,失败 ${fail} 张` : ''}`);
|
||||||
|
} else if (fail) {
|
||||||
|
message.error('上传失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueUploadBatch(files: File[]) {
|
||||||
|
uploadChain.current = uploadChain.current
|
||||||
|
.then(() => uploadBatch(files))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushBatch() {
|
||||||
|
if (batchTimer.current) {
|
||||||
|
clearTimeout(batchTimer.current);
|
||||||
|
batchTimer.current = null;
|
||||||
|
}
|
||||||
|
if (!batchBuf.current.length) return;
|
||||||
|
const files = [...batchBuf.current];
|
||||||
|
batchBuf.current = [];
|
||||||
|
enqueueUploadBatch(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||||
|
batchBuf.current.push(file as File);
|
||||||
|
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
|
||||||
|
if (batchTimer.current) clearTimeout(batchTimer.current);
|
||||||
|
batchTimer.current = setTimeout(() => {
|
||||||
|
flushBatch();
|
||||||
|
}, 80);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
const next = urlsRef.current.filter((_, i) => i !== index);
|
||||||
|
urlsRef.current = next;
|
||||||
|
onChangeRef.current?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{tip ??
|
||||||
|
(maxCount != null
|
||||||
|
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
|
||||||
|
: '支持一次选择多张批量上传')}
|
||||||
|
{maxCount != null ? `(已选 ${urls.length}/${maxCount})` : urls.length ? `(已选 ${urls.length})` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
{urls.length > 0 ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={12}>
|
||||||
|
{urls.map((url, index) => (
|
||||||
|
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
|
||||||
|
{isPdf(url) ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
|
||||||
|
<span>PDF</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => removeAt(index)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'rgba(255,255,255,0.85)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
accept={accept}
|
||||||
|
multiple
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
disabled={uploading || !canAdd}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
|
||||||
|
{canAdd ? buttonText : '已达上限'}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Image, Input, Space, Upload, message } from 'antd';
|
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||||
|
|
||||||
@@ -15,6 +15,14 @@ type OssUploadProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isImageUrl(url: string) {
|
||||||
|
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfUrl(url: string) {
|
||||||
|
return /\.pdf(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
export default function OssUpload({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -25,6 +33,7 @@ export default function OssUpload({
|
|||||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||||
}: OssUploadProps) {
|
}: OssUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||||
@@ -47,6 +56,57 @@ export default function OssUpload({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filePreview =
|
||||||
|
value && mediaType === 'FILE' ? (
|
||||||
|
isImageUrl(value) ? (
|
||||||
|
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
) : isPdfUrl(value) ? (
|
||||||
|
<Space direction="vertical" size={8}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
color: '#cf1322',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePdfOutlined style={{ fontSize: 36 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF 合同</span>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
|
||||||
|
新窗口打开
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Modal
|
||||||
|
title="签约合同预览"
|
||||||
|
open={pdfPreviewOpen}
|
||||||
|
onCancel={() => setPdfPreviewOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width="90vw"
|
||||||
|
styles={{ body: { height: '75vh', padding: 0 } }}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
|
||||||
|
</Modal>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
|
||||||
|
打开已上传文件
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
{value && mediaType === 'IMAGE' && (
|
{value && mediaType === 'IMAGE' && (
|
||||||
@@ -55,6 +115,7 @@ export default function OssUpload({
|
|||||||
{value && mediaType === 'VIDEO' && (
|
{value && mediaType === 'VIDEO' && (
|
||||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||||
)}
|
)}
|
||||||
|
{filePreview}
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Upload
|
<Upload
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
import MultiImageUpload from './MultiImageUpload';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 套餐多图:批量上传,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
|
||||||
|
export default function PackageImagesUpload({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<MultiImageUpload
|
||||||
|
bizType="STORE_PACKAGE"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
|
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -243,6 +244,31 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
|
|||||||
.filter(Boolean) as MenuProps['items'];
|
.filter(Boolean) as MenuProps['items'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
|
||||||
|
if (!items) return items;
|
||||||
|
return items.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||||
|
if ('children' in item && Array.isArray(item.children)) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount),
|
||||||
|
} as MenuItem;
|
||||||
|
}
|
||||||
|
if (String(item.key) === '/store-package-audits') {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
套餐审核
|
||||||
|
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
} as MenuItem;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
@@ -250,11 +276,28 @@ export default function AdminLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
|
const [packagePendingCount, setPackagePendingCount] = useState(0);
|
||||||
|
|
||||||
|
function refreshPackagePendingCount() {
|
||||||
|
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
|
||||||
|
.then((data) => setPackagePendingCount(data.pendingCount ?? 0))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPackagePendingCount();
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChanged = () => refreshPackagePendingCount();
|
||||||
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
@@ -273,10 +316,12 @@ export default function AdminLayout() {
|
|||||||
: location.pathname;
|
: location.pathname;
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
if (!profile) return MENU_ITEMS;
|
const base =
|
||||||
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
!profile || profile.adminRole === 'SUPER_ADMIN'
|
||||||
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
? MENU_ITEMS
|
||||||
}, [profile]);
|
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||||
|
return attachPackageAuditBadge(base, packagePendingCount);
|
||||||
|
}, [profile, packagePendingCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -168,6 +168,12 @@ export type AdminUserRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
|
/** 好客权益·累计获得(含已使用,不含退款作废) */
|
||||||
|
benefitTotalAmount?: number;
|
||||||
|
/** 好客权益·已使用(已核销) */
|
||||||
|
benefitUsedAmount?: number;
|
||||||
|
/** 好客权益·剩余未使用 */
|
||||||
|
benefitBalance?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminOrderItem = {
|
export type AdminOrderItem = {
|
||||||
|
|||||||
@@ -140,10 +140,29 @@ export function fmtTime(v?: string | null) {
|
|||||||
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手机号脱敏展示:138****5678;空值返回 — */
|
/** 联系电话脱敏:手机 138****5678;座机 0379-****888;空值返回 — */
|
||||||
export function maskPhone(phone?: string | null): string {
|
export function maskPhone(phone?: string | null): string {
|
||||||
const raw = String(phone ?? '').trim();
|
const raw = String(phone ?? '').trim().replace(/\s+/g, '');
|
||||||
if (!raw) return '—';
|
if (!raw) return '—';
|
||||||
|
if (/^1[3-9]\d{9}$/.test(raw)) {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||||
|
}
|
||||||
|
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
|
||||||
|
const extMatch = raw.match(/-(\d{1,6})$/);
|
||||||
|
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
|
||||||
|
const ext = hasExt ? extMatch![1] : '';
|
||||||
|
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
|
||||||
|
const digits = main.replace(/\D/g, '');
|
||||||
|
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
|
||||||
|
const area = digits.slice(0, areaLen);
|
||||||
|
const local = digits.slice(areaLen);
|
||||||
|
const keepTail = Math.min(4, Math.max(2, local.length - 4));
|
||||||
|
const maskedLocal =
|
||||||
|
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
|
||||||
|
const joiner = main.includes('-') ? '-' : '';
|
||||||
|
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
|
||||||
|
}
|
||||||
const digits = raw.replace(/\D/g, '');
|
const digits = raw.replace(/\D/g, '');
|
||||||
if (digits.length >= 11) {
|
if (digits.length >= 11) {
|
||||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||||
|
|
||||||
export type StoreCreateForm = {
|
export type StoreCreateForm = {
|
||||||
partnerAccountId: string;
|
partnerAccountId: string;
|
||||||
cityId: string;
|
cityId: string;
|
||||||
@@ -15,6 +17,8 @@ export type StoreCreateForm = {
|
|||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
benefitUsageRule?: string;
|
benefitUsageRule?: string;
|
||||||
|
/** 对外联系电话(店长);可与登录号不同,支持座机 */
|
||||||
|
contactPhone?: string;
|
||||||
openTime?: string;
|
openTime?: string;
|
||||||
closeTime?: string;
|
closeTime?: string;
|
||||||
openTime2?: string;
|
openTime2?: string;
|
||||||
@@ -22,11 +26,13 @@ export type StoreCreateForm = {
|
|||||||
avgPrice?: number | null;
|
avgPrice?: number | null;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
contractUrl?: string;
|
/** 签约合同,支持多张照片 / PDF */
|
||||||
|
contractUrls?: string[];
|
||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
|
sortOrder?: number;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
};
|
};
|
||||||
@@ -57,6 +63,7 @@ export function validateStoreCreateStep1(
|
|||||||
| 'openTime2'
|
| 'openTime2'
|
||||||
| 'closeTime2'
|
| 'closeTime2'
|
||||||
| 'avgPrice'
|
| 'avgPrice'
|
||||||
|
| 'contactPhone'
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||||
@@ -66,6 +73,8 @@ export function validateStoreCreateStep1(
|
|||||||
if (!form.name?.trim()) return '请填写门店名称';
|
if (!form.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||||
|
const contactPhone = form.contactPhone?.trim();
|
||||||
|
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
|
||||||
if (!form.address?.trim()) return '请填写详细地址';
|
if (!form.address?.trim()) return '请填写详细地址';
|
||||||
|
|
||||||
const openTime = String(form.openTime || '').trim();
|
const openTime = String(form.openTime || '').trim();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function mapToForm(d: Record<string, unknown>) {
|
|||||||
const row = d as Row;
|
const row = d as Row;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
|
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
|
||||||
features: row.features?.length
|
features: row.features?.length
|
||||||
? row.features
|
? row.features
|
||||||
: [{ icon: 'star', title: '', desc: '' }],
|
: [{ icon: 'star', title: '', desc: '' }],
|
||||||
@@ -68,12 +68,14 @@ function buildPayload(v: TemplateFormValues) {
|
|||||||
function TemplateContentFields() {
|
function TemplateContentFields() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改)</Typography.Text>
|
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改,支持批量上传)</Typography.Text>
|
||||||
<DetailImageUrlList
|
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
|
||||||
label="详情图"
|
<DetailImageUrlList
|
||||||
bizType="DETAIL_TEMPLATE"
|
label="详情图"
|
||||||
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
bizType="DETAIL_TEMPLATE"
|
||||||
/>
|
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Form.Item name="storyTitle" label="故事标题">
|
<Form.Item name="storyTitle" label="故事标题">
|
||||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||||
@@ -228,7 +230,7 @@ export default function ProductDetailTemplatesPage() {
|
|||||||
}} width={640}>
|
}} width={640}>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{
|
<Form form={createForm} layout="vertical" initialValues={{
|
||||||
status: 'ACTIVE', sortOrder: 0,
|
status: 'ACTIVE', sortOrder: 0,
|
||||||
detailImageUrls: [''],
|
detailImageUrls: [],
|
||||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
}}>
|
}}>
|
||||||
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { request } from '../lib/api';
|
|||||||
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import MultiImageUpload from '../components/MultiImageUpload';
|
||||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
@@ -73,8 +74,8 @@ function mapDetailToForm(d: Record<string, unknown>) {
|
|||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||||
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
|
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
|
||||||
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
|
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
|
||||||
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
||||||
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
||||||
allowOnSitePickup: !!row.allowOnSitePickup,
|
allowOnSitePickup: !!row.allowOnSitePickup,
|
||||||
@@ -129,25 +130,13 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
|
|
||||||
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
||||||
return (
|
return (
|
||||||
<Form.List name={name}>
|
<Form.Item name={name} style={{ marginBottom: 0 }}>
|
||||||
{(fields, { add, remove }) => (
|
<MultiImageUpload
|
||||||
<>
|
bizType={bizType}
|
||||||
{fields.map((field) => (
|
mediaType="IMAGE"
|
||||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
tip={`${label}支持一次选择多张批量上传`}
|
||||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
/>
|
||||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
</Form.Item>
|
||||||
</Form.Item>
|
|
||||||
{fields.length > 1 && (
|
|
||||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
))}
|
|
||||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
|
||||||
添加{label}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +145,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
|||||||
<>
|
<>
|
||||||
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL,单张最大 10MB)</Typography.Text>
|
<Typography.Text type="secondary">详情页轮播(CAROUSEL,支持批量上传,单张最大 10MB)</Typography.Text>
|
||||||
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography.Text type="secondary">详情长图(DETAIL,可逐张修改,单张最大 10MB)</Typography.Text>
|
<Typography.Text type="secondary">详情长图(DETAIL,支持批量上传,单张最大 10MB)</Typography.Text>
|
||||||
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
|
||||||
|
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
||||||
|
</Form.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Form.Item name="storyTitle" label="故事标题">
|
<Form.Item name="storyTitle" label="故事标题">
|
||||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||||
@@ -468,7 +459,7 @@ export default function ProductsPage() {
|
|||||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||||
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
||||||
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
||||||
carouselUrls: [''], detailImageUrls: [''],
|
carouselUrls: [], detailImageUrls: [],
|
||||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
}}>
|
}}>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
Image,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
@@ -13,19 +16,97 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type {
|
import type {
|
||||||
StorePackageAuditDetailDto,
|
StorePackageAuditDetailDto,
|
||||||
|
StorePackageAuditSummaryDto,
|
||||||
StorePackageChangeRequestDto,
|
StorePackageChangeRequestDto,
|
||||||
|
StorePackageChangeStatus,
|
||||||
StorePackageItemDto,
|
StorePackageItemDto,
|
||||||
StorePackageViewDto,
|
StorePackageViewDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||||
|
PENDING: '待审核',
|
||||||
|
APPROVED: '已通过',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||||
const name = String(pkg.name ?? '').trim();
|
const name = String(pkg.name ?? '').trim();
|
||||||
return name ? `name:${name}` : `idx:${index}`;
|
return name ? `name:${name}` : `idx:${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||||
|
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||||||
|
|
||||||
|
/** 文本逐字差异:LCS 比对,产出 equal / delete / insert 段落,用于高亮具体变了哪些字 */
|
||||||
|
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||||
|
for (let i = m - 1; i >= 0; i--) {
|
||||||
|
for (let j = n - 1; j >= 0; j--) {
|
||||||
|
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < m && j < n) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
raw.push({ type: 'equal', text: a[i] });
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
raw.push({ type: 'delete', text: a[i] });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
raw.push({ type: 'insert', text: b[j] });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
||||||
|
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
||||||
|
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||||
|
for (const s of raw) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && last.type === s.type) last.text += s.text;
|
||||||
|
else merged.push({ ...s });
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 逐字段比较套餐内容,返回发生变化的字段明细(用于“变动的地方详细列出”) */
|
||||||
|
function fieldChanges(
|
||||||
|
live: StorePackageItemDto | StorePackageViewDto,
|
||||||
|
proposed: StorePackageItemDto | StorePackageViewDto,
|
||||||
|
): FieldChange[] {
|
||||||
|
const changes: FieldChange[] = [];
|
||||||
|
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||||||
|
const pushText = (label: string, oldV: string, newV: string) => {
|
||||||
|
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||||||
|
};
|
||||||
|
const pushValue = (label: string, oldV: string, newV: string) => {
|
||||||
|
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
||||||
|
};
|
||||||
|
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
||||||
|
pushText('套餐名称', text(live.name), text(proposed.name));
|
||||||
|
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
||||||
|
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
||||||
|
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
||||||
|
const liveImgs = normalizeStorePackageImageUrls(live);
|
||||||
|
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
||||||
|
if (imageSignature(live) !== imageSignature(proposed)) {
|
||||||
|
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||||
@@ -35,6 +116,7 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
|
|||||||
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||||
live?: StorePackageViewDto;
|
live?: StorePackageViewDto;
|
||||||
proposed?: StorePackageItemDto;
|
proposed?: StorePackageItemDto;
|
||||||
|
changes?: FieldChange[];
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -49,8 +131,15 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
|
|||||||
l.price !== p.price ||
|
l.price !== p.price ||
|
||||||
l.dishes !== p.dishes ||
|
l.dishes !== p.dishes ||
|
||||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
|
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||||
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
|
imageSignature(l) !== imageSignature(p);
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
change: changed ? 'changed' : 'unchanged',
|
||||||
|
live: l,
|
||||||
|
proposed: p,
|
||||||
|
changes: changed ? fieldChanges(l, p) : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
@@ -63,12 +152,158 @@ const CHANGE_LABELS = {
|
|||||||
unchanged: { text: '未变', color: 'default' },
|
unchanged: { text: '未变', color: 'default' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */
|
||||||
|
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||||
|
const segs = diffText(oldText, newText);
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
|
<Typography.Text type="secondary">原:</Typography.Text>
|
||||||
|
{segs
|
||||||
|
.filter((s) => s.type !== 'insert')
|
||||||
|
.map((s, idx) =>
|
||||||
|
s.type === 'delete' ? (
|
||||||
|
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
||||||
|
{s.text || '(空)'}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
|
<Typography.Text type="secondary">新:</Typography.Text>
|
||||||
|
{segs
|
||||||
|
.filter((s) => s.type !== 'delete')
|
||||||
|
.map((s, idx) =>
|
||||||
|
s.type === 'insert' ? (
|
||||||
|
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
||||||
|
{s.text || '(空)'}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageDetailCard({
|
||||||
|
title,
|
||||||
|
pkg,
|
||||||
|
change,
|
||||||
|
changes,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||||
|
change?: keyof typeof CHANGE_LABELS;
|
||||||
|
changes?: FieldChange[];
|
||||||
|
}) {
|
||||||
|
const images = normalizeStorePackageImageUrls(pkg);
|
||||||
|
const meta = change ? CHANGE_LABELS[change] : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: 12,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space style={{ marginBottom: 8 }} wrap>
|
||||||
|
{title ? (
|
||||||
|
<Typography.Text type="secondary">{title}</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<strong>{pkg.name}</strong>
|
||||||
|
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||||
|
</div>
|
||||||
|
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||||
|
{pkg.dishes || '—'}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{pkg.usableTime ? (
|
||||||
|
<Typography.Paragraph
|
||||||
|
type="secondary"
|
||||||
|
className="admin-package-audit-text"
|
||||||
|
style={{ marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
可用时间:{pkg.usableTime}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
{pkg.otherNotes ? (
|
||||||
|
<Typography.Paragraph
|
||||||
|
type="secondary"
|
||||||
|
className="admin-package-audit-text"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
其他说明:{pkg.otherNotes}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
{images.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{images.map((url) => (
|
||||||
|
<Image
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
width={72}
|
||||||
|
height={72}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||||
|
)}
|
||||||
|
{changes && changes.length ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: '#fff7e6',
|
||||||
|
border: '1px solid #ffe7ba',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||||
|
变更明细
|
||||||
|
</Typography.Text>
|
||||||
|
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||||
|
{changes.map((c) => (
|
||||||
|
<li key={c.label} style={{ marginBottom: 6 }}>
|
||||||
|
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
||||||
|
{c.kind === 'text' ? (
|
||||||
|
<TextDiff oldText={c.old} newText={c.now} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography.Text delete type="secondary">
|
||||||
|
{c.old}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary"> → </Typography.Text>
|
||||||
|
<Typography.Text strong>{c.now}</Typography.Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function StorePackageAuditsPage() {
|
export default function StorePackageAuditsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [status, setStatus] = useState<string>('PENDING');
|
const [status, setStatus] = useState<string>('PENDING');
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
@@ -84,12 +319,14 @@ export default function StorePackageAuditsPage() {
|
|||||||
pageSize: '20',
|
pageSize: '20',
|
||||||
});
|
});
|
||||||
if (nextStatus) qs.set('status', nextStatus);
|
if (nextStatus) qs.set('status', nextStatus);
|
||||||
const data = await request<Paginated<StorePackageChangeRequestDto>>(
|
const [data, summary] = await Promise.all([
|
||||||
`/admin/store-package-audits?${qs}`,
|
request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`),
|
||||||
);
|
request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'),
|
||||||
|
]);
|
||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
setPage(data.page);
|
setPage(data.page);
|
||||||
|
setPendingCount(summary.pendingCount ?? 0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '加载失败');
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,6 +338,14 @@ export default function StorePackageAuditsPage() {
|
|||||||
void reload(1, status);
|
void reload(1, status);
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
useEffect(() => {
|
||||||
|
const rid = searchParams.get('requestId');
|
||||||
|
if (rid) void openDetail(rid);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
@@ -126,6 +371,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
});
|
});
|
||||||
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||||
setDetailOpen(false);
|
setDetailOpen(false);
|
||||||
|
notifyPackageAuditChanged();
|
||||||
void reload(page, status);
|
void reload(page, status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '操作失败');
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
@@ -133,54 +379,12 @@ export default function StorePackageAuditsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||||
|
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||||
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
|
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||||
{
|
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||||
title: '变更',
|
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||||
dataIndex: 'change',
|
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||||
width: 72,
|
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||||
render: (v: keyof typeof CHANGE_LABELS) => {
|
|
||||||
const meta = CHANGE_LABELS[v];
|
|
||||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '当前线上',
|
|
||||||
render: (_, row) =>
|
|
||||||
row.live ? (
|
|
||||||
<div>
|
|
||||||
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
|
|
||||||
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
|
|
||||||
{row.live.usableTime ? (
|
|
||||||
<div><Typography.Text type="secondary">可用:{row.live.usableTime}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
{row.live.otherNotes ? (
|
|
||||||
<div><Typography.Text type="secondary">备注:{row.live.otherNotes}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '申请变更',
|
|
||||||
render: (_, row) =>
|
|
||||||
row.proposed ? (
|
|
||||||
<div>
|
|
||||||
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
|
|
||||||
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
|
|
||||||
{row.proposed.usableTime ? (
|
|
||||||
<div><Typography.Text type="secondary">可用:{row.proposed.usableTime}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
{row.proposed.otherNotes ? (
|
|
||||||
<div><Typography.Text type="secondary">备注:{row.proposed.otherNotes}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||||
@@ -188,7 +392,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (v: StorePackageChangeRequestDto['status']) => (
|
render: (v: StorePackageChangeRequestDto['status']) => (
|
||||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -238,7 +442,15 @@ export default function StorePackageAuditsPage() {
|
|||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||||
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||||
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
|
{s === 'PENDING' ? (
|
||||||
|
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||||
|
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
||||||
|
</Badge>
|
||||||
|
) : s ? (
|
||||||
|
HQ_PACKAGE_STATUS_LABELS[s]
|
||||||
|
) : (
|
||||||
|
'全部'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -257,7 +469,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||||
width={720}
|
width={880}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -282,8 +494,8 @@ export default function StorePackageAuditsPage() {
|
|||||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<>
|
<>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -291,16 +503,54 @@ export default function StorePackageAuditsPage() {
|
|||||||
{detail.rejectReason ? (
|
{detail.rejectReason ? (
|
||||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography.Paragraph type="secondary">
|
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||||||
线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条
|
<Typography.Text type="secondary">
|
||||||
</Typography.Paragraph>
|
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||||
<Table
|
</Typography.Text>
|
||||||
size="small"
|
<Space wrap>
|
||||||
rowKey="key"
|
<Tag color="green">新增 {addedCount}</Tag>
|
||||||
columns={diffColumns}
|
<Tag color="red">删除 {removedCount}</Tag>
|
||||||
dataSource={diffRows}
|
<Tag color="orange">变更 {changedCount}</Tag>
|
||||||
pagination={false}
|
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||||||
/>
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<div className="admin-package-audit-cols">
|
||||||
|
<div className="admin-package-audit-col">
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
线上已审核套餐
|
||||||
|
</Typography.Title>
|
||||||
|
{(detail.livePackages ?? []).length ? (
|
||||||
|
(detail.livePackages ?? []).map((pkg, index) => (
|
||||||
|
<PackageDetailCard
|
||||||
|
key={`live-${packageKey(pkg, index)}`}
|
||||||
|
title={`套餐 ${index + 1}`}
|
||||||
|
pkg={pkg}
|
||||||
|
change={changeByKey.get(packageKey(pkg, index))}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-package-audit-col">
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
待审核套餐
|
||||||
|
</Typography.Title>
|
||||||
|
{(detail.packages ?? []).length ? (
|
||||||
|
(detail.packages ?? []).map((pkg, index) => (
|
||||||
|
<PackageDetailCard
|
||||||
|
key={`pending-${packageKey(pkg, index)}`}
|
||||||
|
title={`套餐 ${index + 1}`}
|
||||||
|
pkg={pkg}
|
||||||
|
change={changeByKey.get(packageKey(pkg, index))}
|
||||||
|
changes={changesByKey.get(packageKey(pkg, index))}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -23,11 +23,11 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
import { EnvironmentOutlined } from '@ant-design/icons';
|
||||||
|
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import {
|
import {
|
||||||
ADMIN_OPTIONS_PAGE_SIZE,
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
RESOURCE_BIZ_TYPE_LABELS,
|
|
||||||
STORE_AUDIT_STATUS_LABELS,
|
STORE_AUDIT_STATUS_LABELS,
|
||||||
STORE_STATUS_LABELS,
|
STORE_STATUS_LABELS,
|
||||||
fmtTime,
|
fmtTime,
|
||||||
@@ -41,8 +41,11 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
import { resolveRegionBinding } from '../lib/china-region';
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import MultiImageUpload from '../components/MultiImageUpload';
|
||||||
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||||
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
|
import AdminStorePackagesSection, {
|
||||||
|
type AdminStorePackagesHandle,
|
||||||
|
} from '../components/AdminStorePackagesSection';
|
||||||
|
|
||||||
const CREATE_STEPS = [
|
const CREATE_STEPS = [
|
||||||
{ title: '基本信息' },
|
{ title: '基本信息' },
|
||||||
@@ -82,18 +85,6 @@ type StoreMediaItem = {
|
|||||||
url?: string | null;
|
url?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isImageMedia(url: string, mediaType?: string) {
|
|
||||||
if (mediaType === 'IMAGE') return true;
|
|
||||||
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
|
|
||||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
|
||||||
}
|
|
||||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPdfUrl(url: string) {
|
|
||||||
return /\.pdf(\?|#|$)/i.test(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectMediaUrls(detail: Record<string, unknown>) {
|
function collectMediaUrls(detail: Record<string, unknown>) {
|
||||||
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
|
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
|
||||||
const byType = (bizType: string) =>
|
const byType = (bizType: string) =>
|
||||||
@@ -119,161 +110,45 @@ function collectMediaUrls(detail: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) {
|
function StoreAuditMediaEditor() {
|
||||||
const { covers, envs, contracts } = collectMediaUrls(detail);
|
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
|
||||||
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
|
|
||||||
|
|
||||||
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
<Alert
|
||||||
审核材料
|
type="info"
|
||||||
</Typography.Title>
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
{(covers.length > 0 || envs.length > 0) && (
|
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照、签约合同均最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
|
||||||
<div style={{ marginBottom: 16 }}>
|
/>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
<Form.Item name="coverUrl" label="门头照">
|
||||||
门头照 / 环境照(点击可放大浏览)
|
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||||
</Typography.Text>
|
</Form.Item>
|
||||||
<Image.PreviewGroup>
|
<Form.Item
|
||||||
<Space wrap size={12}>
|
name="envPhotoUrls"
|
||||||
{gallery.map((item) => (
|
label="环境照片"
|
||||||
<div key={item.id} style={{ textAlign: 'center' }}>
|
extra="建议至少 3 张;支持批量上传,最多 20 张。可逐张删除后保存。"
|
||||||
<Image
|
|
||||||
src={item.url}
|
|
||||||
width={112}
|
|
||||||
height={84}
|
|
||||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
|
|
||||||
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
</Image.PreviewGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contracts.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
|
||||||
签约合同
|
|
||||||
</Typography.Text>
|
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
||||||
{contracts.map((item, index) => {
|
|
||||||
const imageLike = isImageMedia(item.url, item.mediaType);
|
|
||||||
const pdf = isPdfUrl(item.url);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id || `${item.url}-${index}`}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 12,
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: '#fafafa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{imageLike ? (
|
|
||||||
<Image.PreviewGroup>
|
|
||||||
<Image
|
|
||||||
src={item.url}
|
|
||||||
width={96}
|
|
||||||
height={72}
|
|
||||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
|
||||||
/>
|
|
||||||
</Image.PreviewGroup>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 96,
|
|
||||||
height: 72,
|
|
||||||
borderRadius: 6,
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px dashed #d9d9d9',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: '#cf1322',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FilePdfOutlined style={{ fontSize: 28 }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography.Text strong>
|
|
||||||
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
|
|
||||||
{contracts.length > 1 ? ` ${index + 1}` : ''}
|
|
||||||
</Typography.Text>
|
|
||||||
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
|
|
||||||
{item.url}
|
|
||||||
</Typography.Text>
|
|
||||||
<Space wrap>
|
|
||||||
{imageLike ? (
|
|
||||||
<Typography.Text type="secondary">点击缩略图放大查看</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
{pdf ? (
|
|
||||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
|
|
||||||
页内预览 PDF
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
icon={<LinkOutlined />}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
href={item.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
新窗口打开
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="合同预览"
|
|
||||||
open={!!pdfUrl}
|
|
||||||
onCancel={() => setPdfUrl(null)}
|
|
||||||
width={900}
|
|
||||||
footer={[
|
|
||||||
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
|
|
||||||
新窗口打开
|
|
||||||
</Button>,
|
|
||||||
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
|
|
||||||
关闭
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
destroyOnClose
|
|
||||||
>
|
>
|
||||||
{pdfUrl ? (
|
<MultiImageUpload
|
||||||
<iframe
|
bizType="STORE_ENV"
|
||||||
title="合同 PDF 预览"
|
mediaType="IMAGE"
|
||||||
src={pdfUrl}
|
maxCount={20}
|
||||||
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
|
tip="环境照支持一次选择多张批量上传"
|
||||||
/>
|
/>
|
||||||
) : null}
|
</Form.Item>
|
||||||
</Modal>
|
<Form.Item
|
||||||
|
name="contractUrls"
|
||||||
|
label="签约合同"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
extra="支持多张合同照片(如首页、盖章页),也可上传 PDF,最多 20 个。"
|
||||||
|
>
|
||||||
|
<MultiImageUpload
|
||||||
|
bizType="STORE_CONTRACT"
|
||||||
|
mediaType="FILE"
|
||||||
|
accept="image/*,.pdf"
|
||||||
|
maxCount={20}
|
||||||
|
buttonText="批量上传合同"
|
||||||
|
tip="合同支持一次选择多张照片批量上传,最多 20 个"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -315,6 +190,8 @@ type StoreRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
|
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
|
||||||
|
pendingPackageAuditId?: string | null;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
cityRef?: { name: string; code: string };
|
cityRef?: { name: string; code: string };
|
||||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||||
@@ -332,6 +209,7 @@ type StoreRow = {
|
|||||||
closeTime2?: string | null;
|
closeTime2?: string | null;
|
||||||
avgPrice?: number | null;
|
avgPrice?: number | null;
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
|
sortOrder?: number;
|
||||||
category?: { id: string; name: string; parentId?: string | null } | null;
|
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -390,6 +268,7 @@ export default function StoresPage() {
|
|||||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [auditing, setAuditing] = useState(false);
|
const [auditing, setAuditing] = useState(false);
|
||||||
@@ -504,12 +383,18 @@ export default function StoresPage() {
|
|||||||
account?.phone ||
|
account?.phone ||
|
||||||
(typeof d.phone === 'string' ? d.phone : undefined);
|
(typeof d.phone === 'string' ? d.phone : undefined);
|
||||||
const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
|
const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
|
||||||
|
const contactPhone =
|
||||||
|
(typeof d.contactPhone === 'string' && d.contactPhone.trim()) ||
|
||||||
|
storePhone ||
|
||||||
|
loginPhone ||
|
||||||
|
'';
|
||||||
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
|
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
|
||||||
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
|
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
|
// 登录手机号(老板);与 StoreAccount 同步
|
||||||
phone: storePhone || loginPhone,
|
phone: loginPhone || storePhone,
|
||||||
|
contactPhone,
|
||||||
intro: d.intro,
|
intro: d.intro,
|
||||||
benefitUsageRule:
|
benefitUsageRule:
|
||||||
d.benefitUsageRule != null &&
|
d.benefitUsageRule != null &&
|
||||||
@@ -518,6 +403,15 @@ export default function StoresPage() {
|
|||||||
? String(d.benefitUsageRule)
|
? String(d.benefitUsageRule)
|
||||||
: '',
|
: '',
|
||||||
coverUrl: d.coverUrl,
|
coverUrl: d.coverUrl,
|
||||||
|
envPhotoUrls: (() => {
|
||||||
|
const { envs } = collectMediaUrls(d);
|
||||||
|
const urls = envs.map((item) => item.url).filter(Boolean);
|
||||||
|
return urls;
|
||||||
|
})(),
|
||||||
|
contractUrls: (() => {
|
||||||
|
const { contracts } = collectMediaUrls(d);
|
||||||
|
return contracts.map((item) => item.url).filter(Boolean);
|
||||||
|
})(),
|
||||||
province: d.province,
|
province: d.province,
|
||||||
city: d.cityName,
|
city: d.cityName,
|
||||||
district: d.district,
|
district: d.district,
|
||||||
@@ -540,6 +434,7 @@ export default function StoresPage() {
|
|||||||
? (d.visibilityPhones as string[])
|
? (d.visibilityPhones as string[])
|
||||||
: [],
|
: [],
|
||||||
isTest: !!d.isTest,
|
isTest: !!d.isTest,
|
||||||
|
sortOrder: d.sortOrder != null ? Number(d.sortOrder) : 0,
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
@@ -557,7 +452,14 @@ export default function StoresPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: v.name,
|
name: v.name,
|
||||||
phone: v.phone,
|
phone: v.phone,
|
||||||
coverUrl: v.coverUrl,
|
contactPhone: String(v.contactPhone || '').trim() || v.phone,
|
||||||
|
coverUrl: v.coverUrl ?? '',
|
||||||
|
envPhotoUrls: Array.isArray(v.envPhotoUrls)
|
||||||
|
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
contractUrls: Array.isArray(v.contractUrls)
|
||||||
|
? v.contractUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
|
||||||
|
: [],
|
||||||
intro: v.intro,
|
intro: v.intro,
|
||||||
benefitUsageRule:
|
benefitUsageRule:
|
||||||
typeof v.benefitUsageRule === 'string' &&
|
typeof v.benefitUsageRule === 'string' &&
|
||||||
@@ -581,6 +483,7 @@ export default function StoresPage() {
|
|||||||
bankBranch: v.bankBranch ?? null,
|
bankBranch: v.bankBranch ?? null,
|
||||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||||
isTest: !!v.isTest,
|
isTest: !!v.isTest,
|
||||||
|
sortOrder: v.sortOrder != null ? Number(v.sortOrder) : 0,
|
||||||
...(hasCoords
|
...(hasCoords
|
||||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -589,7 +492,10 @@ export default function StoresPage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
message.success('门店信息已保存');
|
const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true });
|
||||||
|
message.success(
|
||||||
|
packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存',
|
||||||
|
);
|
||||||
setDetail(updated);
|
setDetail(updated);
|
||||||
setPhoneMismatch(null);
|
setPhoneMismatch(null);
|
||||||
void reload();
|
void reload();
|
||||||
@@ -663,8 +569,9 @@ export default function StoresPage() {
|
|||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
void loadOptions();
|
void loadOptions();
|
||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
envPhotoUrls: ['', '', ''],
|
envPhotoUrls: [],
|
||||||
settlementRate: 60,
|
settlementRate: 60,
|
||||||
|
sortOrder: 0,
|
||||||
openTime: '10:00',
|
openTime: '10:00',
|
||||||
closeTime: '22:00',
|
closeTime: '22:00',
|
||||||
openTime2: undefined,
|
openTime2: undefined,
|
||||||
@@ -715,6 +622,7 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
||||||
|
const contractUrls = (values.contractUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
||||||
await request('/admin/stores', {
|
await request('/admin/stores', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -725,6 +633,7 @@ export default function StoresPage() {
|
|||||||
city: values.city,
|
city: values.city,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
phone: values.phone.trim(),
|
phone: values.phone.trim(),
|
||||||
|
contactPhone: String(values.contactPhone || values.phone || '').trim(),
|
||||||
district: values.district.trim(),
|
district: values.district.trim(),
|
||||||
address: values.address.trim(),
|
address: values.address.trim(),
|
||||||
...(values.latitude != null &&
|
...(values.latitude != null &&
|
||||||
@@ -748,11 +657,12 @@ export default function StoresPage() {
|
|||||||
: {}),
|
: {}),
|
||||||
coverUrl: values.coverUrl?.trim() || undefined,
|
coverUrl: values.coverUrl?.trim() || undefined,
|
||||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
contractUrl: values.contractUrl?.trim() || undefined,
|
contractUrls: contractUrls.length ? contractUrls : undefined,
|
||||||
bankAccountName: values.bankAccountName.trim(),
|
bankAccountName: values.bankAccountName.trim(),
|
||||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||||
bankBranch: values.bankBranch.trim(),
|
bankBranch: values.bankBranch.trim(),
|
||||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||||
|
sortOrder: values.sortOrder != null ? Number(values.sortOrder) : 0,
|
||||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -777,19 +687,37 @@ export default function StoresPage() {
|
|||||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||||
},
|
},
|
||||||
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
|
{
|
||||||
<Space size={4}>
|
title: '门店名',
|
||||||
<span>{v}</span>
|
dataIndex: 'name',
|
||||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
width: 180,
|
||||||
</Space>
|
ellipsis: { showTitle: false },
|
||||||
) },
|
render: (v: string, row) => {
|
||||||
|
const name = v || '—';
|
||||||
|
return (
|
||||||
|
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
|
||||||
|
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
|
||||||
|
{name}
|
||||||
|
</Typography.Text>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '分类',
|
title: '分类',
|
||||||
width: 100,
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
render: (_, row) => row.category?.name || '—',
|
render: (_, row) => row.category?.name || '—',
|
||||||
},
|
},
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
|
||||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
{ title: '登录号', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '联系电话',
|
||||||
|
dataIndex: 'contactPhone',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string | null | undefined, row) => v || row.phone,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '营业状态', dataIndex: 'status', width: 90,
|
title: '营业状态', dataIndex: 'status', width: 90,
|
||||||
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
||||||
@@ -800,10 +728,10 @@ export default function StoresPage() {
|
|||||||
const status = s || 'APPROVED';
|
const status = s || 'APPROVED';
|
||||||
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
|
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
|
||||||
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
|
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
|
||||||
{status === 'REJECTED' && row.rejectReason ? (
|
{status === 'REJECTED' && row.rejectReason ? (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
|
||||||
{row.rejectReason}
|
{row.rejectReason}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -815,8 +743,16 @@ export default function StoresPage() {
|
|||||||
title: '开城合伙人',
|
title: '开城合伙人',
|
||||||
dataIndex: 'partner',
|
dataIndex: 'partner',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (partner: StoreRow['partner']) =>
|
ellipsis: { showTitle: false },
|
||||||
partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—',
|
render: (partner: StoreRow['partner']) => {
|
||||||
|
if (!partner) return '—';
|
||||||
|
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
|
||||||
|
return (
|
||||||
|
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
|
||||||
|
{label}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '可见',
|
title: '可见',
|
||||||
@@ -826,14 +762,35 @@ export default function StoresPage() {
|
|||||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||||
|
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 140,
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0} wrap>
|
||||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
||||||
|
{row.pendingPackageAuditId ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||||
|
>
|
||||||
|
审核套餐
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||||
|
>
|
||||||
|
对比
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -901,8 +858,24 @@ export default function StoresPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
<Table
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
rowKey="id"
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1720 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
@@ -999,18 +972,34 @@ export default function StoresPage() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
|
message={`主账号登录号为 ${phoneMismatch},与门店登录字段不一致。保存「登录手机号」将同步到门店端登录账号。`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="phone"
|
name="phone"
|
||||||
label="登录手机号"
|
label="登录手机号(老板)"
|
||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
extra="门店端短信登录使用此号码;修改后需用新号重新登录"
|
extra="门店端主账号短信登录;修改后需用新号重新登录"
|
||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="contactPhone"
|
||||||
|
label="联系电话(店长/对外)"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写对外联系电话' },
|
||||||
|
{
|
||||||
|
validator: (_, value) =>
|
||||||
|
isStoreContactPhone(String(value || ''))
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机"
|
||||||
|
>
|
||||||
|
<Input placeholder="手机号或座机,如 0379-8888888" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="categoryParentId"
|
name="categoryParentId"
|
||||||
label="门店分类(大类)"
|
label="门店分类(大类)"
|
||||||
@@ -1105,6 +1094,13 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="sortOrder"
|
||||||
|
label="排序"
|
||||||
|
extra="数值越小越靠前;同排序时按距离(有定位)或创建时间"
|
||||||
|
>
|
||||||
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
|
||||||
|
</Form.Item>
|
||||||
<Space wrap style={{ width: '100%' }}>
|
<Space wrap style={{ width: '100%' }}>
|
||||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||||
<Input type="time" style={{ width: 140 }} />
|
<Input type="time" style={{ width: 140 }} />
|
||||||
@@ -1174,12 +1170,14 @@ export default function StoresPage() {
|
|||||||
{
|
{
|
||||||
key: 'media',
|
key: 'media',
|
||||||
label: '审核材料',
|
label: '审核材料',
|
||||||
children: <StoreAuditMediaSection detail={detail} />,
|
forceRender: true,
|
||||||
|
children: <StoreAuditMediaEditor />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'packages',
|
key: 'packages',
|
||||||
label: '套餐',
|
label: '套餐',
|
||||||
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
|
forceRender: true,
|
||||||
|
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -1277,8 +1275,27 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||||
<Input placeholder="请输入门店名称" />
|
<Input placeholder="请输入门店名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
<Form.Item name="phone" label="登录手机号(老板)" rules={[{ required: true, message: '请填写登录手机号' }]}>
|
||||||
<Input placeholder="11位手机号" />
|
<Input placeholder="门店端主账号登录" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="contactPhone"
|
||||||
|
label="联系电话(店长/对外)"
|
||||||
|
extra="用户端拨号展示;留空则与登录号相同。支持座机"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (_, value) => {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw || isStoreContactPhone(raw)) return Promise.resolve();
|
||||||
|
return Promise.reject(new Error(STORE_CONTACT_PHONE_HINT));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="手机号或座机,如 0379-8888888" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
|
||||||
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||||
@@ -1364,26 +1381,31 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="coverUrl" label="门头照">
|
<Form.Item name="coverUrl" label="门头照">
|
||||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Typography.Text strong>环境照片</Typography.Text>
|
<Form.Item
|
||||||
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
name="envPhotoUrls"
|
||||||
至少 3 张,可继续添加
|
label="环境照片"
|
||||||
</Typography.Paragraph>
|
extra="选填;支持批量上传,最多 20 张"
|
||||||
<Form.List name="envPhotoUrls">
|
>
|
||||||
{(fields, { add }) => (
|
<MultiImageUpload
|
||||||
<div style={{ marginTop: 8 }}>
|
bizType="STORE_ENV"
|
||||||
{fields.map((field, index) => (
|
mediaType="IMAGE"
|
||||||
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
|
maxCount={20}
|
||||||
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
|
tip="环境照支持一次选择多张批量上传"
|
||||||
</Form.Item>
|
/>
|
||||||
))}
|
</Form.Item>
|
||||||
<Button type="dashed" onClick={() => add('')} block>
|
<Form.Item
|
||||||
添加环境照片
|
name="contractUrls"
|
||||||
</Button>
|
label="签约合同"
|
||||||
</div>
|
extra="选填;支持多张合同照片或 PDF,最多 20 个"
|
||||||
)}
|
>
|
||||||
</Form.List>
|
<MultiImageUpload
|
||||||
<Form.Item name="contractUrl" label="签约合同">
|
bizType="STORE_CONTRACT"
|
||||||
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
mediaType="FILE"
|
||||||
|
accept="image/*,.pdf"
|
||||||
|
maxCount={20}
|
||||||
|
buttonText="批量上传合同"
|
||||||
|
tip="合同支持一次选择多张照片批量上传,最多 20 个"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ type UserOrderRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
|
||||||
|
function fmtBenefit(v: number | null | undefined) {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return '—';
|
||||||
|
return `¥${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
type UserBehaviorLog = {
|
type UserBehaviorLog = {
|
||||||
id: string;
|
id: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
@@ -315,6 +322,39 @@ export default function UsersPage() {
|
|||||||
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
||||||
},
|
},
|
||||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
|
{
|
||||||
|
title: '剩余权益',
|
||||||
|
dataIndex: 'benefitBalance',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0),
|
||||||
|
render: (v: number | undefined) =>
|
||||||
|
Number(v ?? 0) > 0 ? (
|
||||||
|
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||||
|
{fmtBenefit(v)}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">—</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已用权益',
|
||||||
|
dataIndex: 'benefitUsedAmount',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
|
||||||
|
render: (v: number | undefined) => (
|
||||||
|
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '累计权益',
|
||||||
|
dataIndex: 'benefitTotalAmount',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0),
|
||||||
|
render: (v: number | undefined) => fmtBenefit(v),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '注册时间',
|
title: '注册时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
@@ -389,7 +429,7 @@ export default function UsersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1500 }}
|
scroll={{ x: 1830 }}
|
||||||
rowSelection={canDeleteUsers ? {
|
rowSelection={canDeleteUsers ? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
preserveSelectedRowKeys: true,
|
preserveSelectedRowKeys: true,
|
||||||
@@ -461,6 +501,18 @@ export default function UsersPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="好客权益">
|
||||||
|
<Space size={16} wrap>
|
||||||
|
<span>
|
||||||
|
剩余{' '}
|
||||||
|
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||||
|
{fmtBenefit(detail.benefitBalance)}
|
||||||
|
</Typography.Text>
|
||||||
|
</span>
|
||||||
|
<span>已用 {fmtBenefit(detail.benefitUsedAmount)}</span>
|
||||||
|
<span>累计 {fmtBenefit(detail.benefitTotalAmount)}</span>
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="注册时间">
|
<Descriptions.Item label="注册时间">
|
||||||
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -51,13 +51,35 @@ type BillItem = {
|
|||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
UNPAID: '未打款',
|
UNPAID: '未打款',
|
||||||
PAID: '已打款',
|
PAID: '已打款',
|
||||||
|
NO_PAYMENT_NEEDED: '无需打款',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
UNPAID: 'red',
|
UNPAID: 'red',
|
||||||
PAID: 'green',
|
PAID: 'green',
|
||||||
|
NO_PAYMENT_NEEDED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isZeroPayable(amount: number | string | null | undefined) {
|
||||||
|
return Number(amount ?? 0) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
|
||||||
|
function displayWineryStatus(status: string, wineryAmount: number | string) {
|
||||||
|
if (isZeroPayable(wineryAmount)) {
|
||||||
|
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: status,
|
||||||
|
label: STATUS_LABELS[status] || status,
|
||||||
|
color: STATUS_COLORS[status] || 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
|
||||||
|
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
|
||||||
|
}
|
||||||
|
|
||||||
const DELIVERY_LABELS: Record<string, string> = {
|
const DELIVERY_LABELS: Record<string, string> = {
|
||||||
LOCAL: '同城',
|
LOCAL: '同城',
|
||||||
CROSS_CITY: '跨城',
|
CROSS_CITY: '跨城',
|
||||||
@@ -212,7 +234,7 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒厂应付',
|
title: '应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
@@ -220,8 +242,11 @@ export default function WineryBillsPage() {
|
|||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 100,
|
||||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
render: (s, row) => {
|
||||||
|
const d = displayWineryStatus(s, row.wineryAmount);
|
||||||
|
return <Tag color={d.color}>{d.label}</Tag>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -232,7 +257,7 @@ export default function WineryBillsPage() {
|
|||||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
明细
|
明细
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === 'UNPAID' && (
|
{canConfirmWineryPay(row) && (
|
||||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||||||
确认打款
|
确认打款
|
||||||
</Button>
|
</Button>
|
||||||
@@ -258,7 +283,8 @@ export default function WineryBillsPage() {
|
|||||||
酒厂对账单
|
酒厂对账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
||||||
|
无需打款(灰),可展开订单明细
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
{canEditWineryBank ? (
|
{canEditWineryBank ? (
|
||||||
@@ -273,7 +299,7 @@ export default function WineryBillsPage() {
|
|||||||
<Space size="large" wrap>
|
<Space size="large" wrap>
|
||||||
<Statistic title="账单数" value={summary.count} />
|
<Statistic title="账单数" value={summary.count} />
|
||||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -296,7 +322,7 @@ export default function WineryBillsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 130 }}
|
||||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -347,7 +373,7 @@ export default function WineryBillsPage() {
|
|||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedKeys,
|
selectedRowKeys: selectedKeys,
|
||||||
onChange: setSelectedKeys,
|
onChange: setSelectedKeys,
|
||||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -368,8 +394,10 @@ export default function WineryBillsPage() {
|
|||||||
<Descriptions column={1} size="small" bordered>
|
<Descriptions column={1} size="small" bordered>
|
||||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
<Descriptions.Item label="状态">
|
||||||
|
{displayWineryStatus(detail.status, detail.wineryAmount).label}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
@@ -395,7 +423,7 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒厂应付',
|
title: '应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@dukang/domain": ["../../packages/domain/src/index.ts"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5175,
|
port: 5175,
|
||||||
proxy: { '/api': 'http://localhost:3000' },
|
proxy: { '/api': apiTarget },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dukang/client-logging": "workspace:*",
|
"@dukang/client-logging": "workspace:*",
|
||||||
|
"@dukang/domain": "workspace:*",
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"@dukang/shared-ui": "workspace:*",
|
"@dukang/shared-ui": "workspace:*",
|
||||||
"@dukang/weixin-sdk": "workspace:*",
|
"@dukang/weixin-sdk": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
|
import { enqueueUpload } from '../lib/upload-lock';
|
||||||
|
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
||||||
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
|
bizType: string;
|
||||||
|
mediaType?: OssMediaType;
|
||||||
|
maxCount?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
/** 系统文件选择器的 accept,默认仅图片 */
|
||||||
|
accept?: string;
|
||||||
|
/** 计量单位文案,如「张」「个」 */
|
||||||
|
unit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isCancelError(msg: string): boolean {
|
||||||
|
return /cancel|取消/i.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(url: string): boolean {
|
||||||
|
return /\.pdf(\?|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrls(value?: string[]) {
|
||||||
|
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合伙人端多图批量上传(微信相册可多选) */
|
||||||
|
export default function MultiOssUploadField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
bizType,
|
||||||
|
mediaType = 'IMAGE',
|
||||||
|
maxCount = 20,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
accept = 'image/*',
|
||||||
|
unit = '张',
|
||||||
|
}: Props) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pickingRef = useRef(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const urls = normalizeUrls(value);
|
||||||
|
const urlsRef = useRef(urls);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const remaining = Math.max(0, maxCount - urls.length);
|
||||||
|
const inWechat = isWechatEnv();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
urlsRef.current = urls;
|
||||||
|
}, [urls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inWechat) return;
|
||||||
|
void weixinSdk.init().catch(() => {});
|
||||||
|
}, [inWechat]);
|
||||||
|
|
||||||
|
function showUploadError(text: string) {
|
||||||
|
setError(text);
|
||||||
|
toastError(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(files: File[]) {
|
||||||
|
const current = urlsRef.current;
|
||||||
|
const room = Math.max(0, maxCount - current.length);
|
||||||
|
const picked = files.slice(0, room);
|
||||||
|
if (!picked.length) {
|
||||||
|
showUploadError(`最多 ${maxCount} ${unit}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
setError('');
|
||||||
|
const appended: string[] = [];
|
||||||
|
try {
|
||||||
|
for (const file of picked) {
|
||||||
|
if (!file.size) continue;
|
||||||
|
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
|
||||||
|
appended.push(result.url);
|
||||||
|
}
|
||||||
|
if (appended.length) {
|
||||||
|
const next = [...urlsRef.current, ...appended];
|
||||||
|
urlsRef.current = next;
|
||||||
|
onChangeRef.current?.(next);
|
||||||
|
toastSuccess(`已上传 ${appended.length} ${unit}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showUploadError(e instanceof Error ? e.message : '上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickWechat() {
|
||||||
|
if (pickingRef.current || uploading || disabled || remaining <= 0) return;
|
||||||
|
pickingRef.current = true;
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await weixinSdk.init();
|
||||||
|
const files = await weixinSdk.chooseImages({
|
||||||
|
count: Math.min(remaining, 9),
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
});
|
||||||
|
if (!files?.length) return;
|
||||||
|
await uploadFiles(files);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||||
|
if (isCancelError(msg)) return;
|
||||||
|
const formatted = formatChooseImageFailMessage(msg) || msg;
|
||||||
|
showUploadError(formatted);
|
||||||
|
inputRef.current?.click();
|
||||||
|
} finally {
|
||||||
|
pickingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
if (disabled) return;
|
||||||
|
onChange?.(urls.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="partner-oss-upload">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple
|
||||||
|
className="partner-oss-upload-input"
|
||||||
|
disabled={disabled || uploading || remaining <= 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const list = Array.from(e.target.files ?? []);
|
||||||
|
if (list.length) void uploadFiles(list);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{urls.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||||
|
{urls.map((url, index) => (
|
||||||
|
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
||||||
|
{isPdf(url) ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
|
background: '#f7f7f7',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
<span className="text-muted">PDF</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!disabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-packages-remove"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
margin: 0,
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'rgba(0,0,0,0.55)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
onClick={() => removeAt(index)}
|
||||||
|
>
|
||||||
|
删
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||||
|
disabled={disabled || uploading || remaining <= 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (inWechat) void pickWechat();
|
||||||
|
else inputRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||||
|
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||||
|
</span>
|
||||||
|
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||||
|
{uploading
|
||||||
|
? '上传中…'
|
||||||
|
: remaining <= 0
|
||||||
|
? `已达上限 ${maxCount}${unit}`
|
||||||
|
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{error ? (
|
||||||
|
<p className="partner-form-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PackageFormItem } from '../lib/storePackages';
|
import type { PackageFormItem } from '../lib/storePackages';
|
||||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
import { emptyPackage } from '../lib/storePackages';
|
import { emptyPackage } from '../lib/storePackages';
|
||||||
import OssUploadField from './OssUploadField';
|
import MultiOssUploadField from './MultiOssUploadField';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
items: PackageFormItem[];
|
items: PackageFormItem[];
|
||||||
@@ -119,38 +119,46 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
|
|||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>使用时间</label>
|
<label>使用时间</label>
|
||||||
<div className="partner-field-input">
|
<textarea
|
||||||
<span className="material-symbols-outlined">schedule</span>
|
rows={2}
|
||||||
<input
|
placeholder="节假日除外"
|
||||||
placeholder="节假日除外"
|
value={item.usableTime || ''}
|
||||||
value={item.usableTime || ''}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>套餐图片</label>
|
<label>套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量)</label>
|
||||||
<OssUploadField
|
<MultiOssUploadField
|
||||||
bizType="STORE_PACKAGE"
|
bizType="STORE_PACKAGE"
|
||||||
value={item.imageUrl || ''}
|
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(url) => updateAt(index, { imageUrl: url })}
|
value={
|
||||||
|
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
|
||||||
|
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
|
||||||
|
: item.imageUrl
|
||||||
|
? [String(item.imageUrl)]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onChange={(imageUrls) =>
|
||||||
|
updateAt(index, {
|
||||||
|
imageUrls,
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>其他说明</label>
|
<label>其他说明</label>
|
||||||
<div className="partner-field-input">
|
<textarea
|
||||||
<span className="material-symbols-outlined">info</span>
|
rows={2}
|
||||||
<input
|
placeholder="不可叠加"
|
||||||
placeholder="不可叠加"
|
value={item.otherNotes || ''}
|
||||||
value={item.otherNotes || ''}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { getDefaultPartnerRegionForm } from './china-region';
|
||||||
|
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||||
|
|
||||||
export type StoreDraftForm = {
|
export type StoreDraftForm = {
|
||||||
regionCodes: string[];
|
regionCodes: string[];
|
||||||
cityId: string;
|
cityId: string;
|
||||||
@@ -5,7 +8,10 @@ export type StoreDraftForm = {
|
|||||||
city: string;
|
city: string;
|
||||||
district: string;
|
district: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** 门店登录手机号(老板主账号) */
|
||||||
phone: string;
|
phone: string;
|
||||||
|
/** 对外联系电话(店长);可与登录号不同 */
|
||||||
|
contactPhone: string;
|
||||||
address: string;
|
address: string;
|
||||||
/** 门店坐标(定位或地理编码) */
|
/** 门店坐标(定位或地理编码) */
|
||||||
latitude: string;
|
latitude: string;
|
||||||
@@ -25,7 +31,8 @@ export type StoreDraftForm = {
|
|||||||
benefitUsageRule: string;
|
benefitUsageRule: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
envPhotoUrls: string[];
|
envPhotoUrls: string[];
|
||||||
contractUrl: string;
|
/** 签约合同,支持多张照片 / PDF */
|
||||||
|
contractUrls: string[];
|
||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
@@ -44,13 +51,12 @@ export function storeDraftKey(accountId?: string): string {
|
|||||||
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
|
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { getDefaultPartnerRegionForm } from './china-region';
|
|
||||||
|
|
||||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||||
...getDefaultPartnerRegionForm(),
|
...getDefaultPartnerRegionForm(),
|
||||||
cityId: '',
|
cityId: '',
|
||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
|
contactPhone: '',
|
||||||
address: '',
|
address: '',
|
||||||
latitude: '',
|
latitude: '',
|
||||||
longitude: '',
|
longitude: '',
|
||||||
@@ -66,7 +72,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
benefitUsageRule: '',
|
benefitUsageRule: '',
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
envPhotoUrls: ['', '', ''],
|
envPhotoUrls: ['', '', ''],
|
||||||
contractUrl: '',
|
contractUrls: [],
|
||||||
bankAccountName: '',
|
bankAccountName: '',
|
||||||
bankAccountNo: '',
|
bankAccountNo: '',
|
||||||
bankBranch: '',
|
bankBranch: '',
|
||||||
@@ -84,6 +90,9 @@ function timeToMinutes(hhmm: string): number {
|
|||||||
|
|
||||||
export const MIN_ENV_PHOTO_COUNT = 3;
|
export const MIN_ENV_PHOTO_COUNT = 3;
|
||||||
|
|
||||||
|
/** 签约合同最多可上传的照片 / PDF 数量 */
|
||||||
|
export const MAX_CONTRACT_COUNT = 20;
|
||||||
|
|
||||||
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||||
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||||
while (arr.length < minLen) arr.push('');
|
while (arr.length < minLen) arr.push('');
|
||||||
@@ -102,6 +111,9 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
|||||||
...raw,
|
...raw,
|
||||||
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||||
cityId: String(raw.cityId ?? base.cityId),
|
cityId: String(raw.cityId ?? base.cityId),
|
||||||
|
phone: String(raw.phone ?? base.phone),
|
||||||
|
contactPhone:
|
||||||
|
String(raw.contactPhone ?? '').trim() || String(raw.phone ?? '').trim() || base.contactPhone,
|
||||||
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
|
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
|
||||||
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
|
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
|
||||||
openTime: String(raw.openTime ?? base.openTime),
|
openTime: String(raw.openTime ?? base.openTime),
|
||||||
@@ -111,6 +123,22 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
|||||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
||||||
|
// 兼容旧草稿:单个 contractUrl 迁移为数组
|
||||||
|
contractUrls: (() => {
|
||||||
|
const list = Array.isArray(raw.contractUrls)
|
||||||
|
? raw.contractUrls
|
||||||
|
: [(raw as { contractUrl?: unknown }).contractUrl];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const url = String(item ?? '').trim();
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
if (out.length >= MAX_CONTRACT_COUNT) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})(),
|
||||||
packages: Array.isArray(raw.packages)
|
packages: Array.isArray(raw.packages)
|
||||||
? raw.packages.map((p, i) => ({
|
? raw.packages.map((p, i) => ({
|
||||||
name: String((p as { name?: string }).name ?? ''),
|
name: String((p as { name?: string }).name ?? ''),
|
||||||
@@ -217,12 +245,13 @@ export function validateStoreStep1(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateStoreStep2(
|
export function validateStoreStep2(
|
||||||
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>,
|
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrls'>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.coverUrl.trim()) return '请上传门头照';
|
if (!form.coverUrl.trim()) return '请上传门头照';
|
||||||
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
||||||
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
||||||
if (!form.contractUrl.trim()) return '请上传签约合同';
|
const contractCount = (form.contractUrls ?? []).filter((u) => u.trim()).length;
|
||||||
|
if (contractCount < 1) return '请上传签约合同';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,14 +265,16 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
|
|||||||
export function validateStoreStep3(
|
export function validateStoreStep3(
|
||||||
form: Pick<
|
form: Pick<
|
||||||
StoreDraftForm,
|
StoreDraftForm,
|
||||||
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
|
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'contactPhone'
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
||||||
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
||||||
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
||||||
if (!form.bankBranch.trim()) return '请填写开户支行';
|
if (!form.bankBranch.trim()) return '请填写开户支行';
|
||||||
if (!form.phone.trim()) return '请填写联系电话';
|
if (!form.phone.trim()) return '请填写门店登录手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店登录手机号须为11位手机号';
|
||||||
|
if (!form.contactPhone.trim()) return '请填写联系电话';
|
||||||
|
if (!isStoreContactPhone(form.contactPhone.trim())) return STORE_CONTACT_PHONE_HINT;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
import {
|
||||||
|
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
||||||
|
STORE_PACKAGE_MAX_COUNT,
|
||||||
|
normalizeStorePackageImageUrls,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
export type PackageFormItem = StorePackageItemDto;
|
export type PackageFormItem = StorePackageItemDto;
|
||||||
|
|
||||||
@@ -11,24 +15,34 @@ export function emptyPackage(index = 0): PackageFormItem {
|
|||||||
usableTime: '',
|
usableTime: '',
|
||||||
otherNotes: '',
|
otherNotes: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
imageUrls: [],
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||||
return raw
|
return raw
|
||||||
.map((item, index) => ({
|
.map((item, index) => {
|
||||||
name: item.name.trim(),
|
const imageUrls = normalizeStorePackageImageUrls(item);
|
||||||
price: item.price.trim(),
|
return {
|
||||||
dishes: item.dishes.trim(),
|
name: item.name.trim(),
|
||||||
usableTime: item.usableTime?.trim() || '',
|
price: item.price.trim(),
|
||||||
otherNotes: item.otherNotes?.trim() || '',
|
dishes: item.dishes.trim(),
|
||||||
imageUrl: item.imageUrl?.trim() || '',
|
usableTime: item.usableTime?.trim() || '',
|
||||||
sortOrder: index,
|
otherNotes: item.otherNotes?.trim() || '',
|
||||||
}))
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
})
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
|
item.name ||
|
||||||
|
item.price ||
|
||||||
|
item.dishes ||
|
||||||
|
item.usableTime ||
|
||||||
|
item.otherNotes ||
|
||||||
|
item.imageUrls.length > 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +57,9 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
|
|||||||
const price = Number(item.price);
|
const price = Number(item.price);
|
||||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||||
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||||
|
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
||||||
|
return `第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | stri
|
|||||||
|
|
||||||
export function storeAuditLabel(auditStatus?: string | null): string {
|
export function storeAuditLabel(auditStatus?: string | null): string {
|
||||||
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
||||||
if (s === 'PENDING') return '待总部审核';
|
if (s === 'PENDING') return '待审核';
|
||||||
if (s === 'REJECTED') return '审核驳回';
|
if (s === 'REJECTED') return '审核驳回';
|
||||||
if (s === 'APPROVED') return '审核通过';
|
if (s === 'APPROVED') return '审核通过';
|
||||||
return auditStatus || '—';
|
return auditStatus || '—';
|
||||||
@@ -21,10 +21,26 @@ export function storeStatusLabel(status: string): string {
|
|||||||
const s = String(status).toUpperCase();
|
const s = String(status).toUpperCase();
|
||||||
if (s === 'OPEN') return '营业中';
|
if (s === 'OPEN') return '营业中';
|
||||||
if (s === 'PAUSED') return '临时闭店';
|
if (s === 'PAUSED') return '临时闭店';
|
||||||
if (s === 'CLOSED') return '永久关闭';
|
if (s === 'CLOSED') return '永久闭店';
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列表右上角统一状态:审核未通过优先于营业状态 */
|
||||||
|
export function storeListBadge(store: {
|
||||||
|
status?: unknown;
|
||||||
|
auditStatus?: unknown;
|
||||||
|
}): { label: string; pillClass: string } {
|
||||||
|
const audit = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||||||
|
if (audit === 'PENDING') {
|
||||||
|
return { label: '待审核', pillClass: storeAuditPillClass('PENDING') };
|
||||||
|
}
|
||||||
|
if (audit === 'REJECTED') {
|
||||||
|
return { label: '审核驳回', pillClass: storeAuditPillClass('REJECTED') };
|
||||||
|
}
|
||||||
|
const status = String(store.status || '').toUpperCase();
|
||||||
|
return { label: storeStatusLabel(status), pillClass: storeStatusPillClass(status) };
|
||||||
|
}
|
||||||
|
|
||||||
export function storeStatusPillClass(status: string): string {
|
export function storeStatusPillClass(status: string): string {
|
||||||
const s = String(status).toUpperCase();
|
const s = String(status).toUpperCase();
|
||||||
if (s === 'OPEN') return 'partner-status-pill--open';
|
if (s === 'OPEN') return 'partner-status-pill--open';
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||||
|
|
||||||
import OssUploadField from '../components/OssUploadField';
|
import OssUploadField from '../components/OssUploadField';
|
||||||
|
import MultiOssUploadField from '../components/MultiOssUploadField';
|
||||||
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { fetchClientConfig } from '../lib/wechat-auth';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
|
||||||
import { resolveRegionBinding } from '../lib/china-region';
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
@@ -40,15 +42,18 @@ import {
|
|||||||
|
|
||||||
validateStoreStep3,
|
validateStoreStep3,
|
||||||
|
|
||||||
patchEnvPhotoAt,
|
|
||||||
addEnvPhotoSlot,
|
|
||||||
MIN_ENV_PHOTO_COUNT,
|
MIN_ENV_PHOTO_COUNT,
|
||||||
|
|
||||||
|
MAX_CONTRACT_COUNT,
|
||||||
} from '../lib/storeDraft';
|
} from '../lib/storeDraft';
|
||||||
import StorePackagesForm from '../components/StorePackagesForm';
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||||
|
|
||||||
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
|
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
|
||||||
|
|
||||||
|
const DEFAULT_PARTNER_ONBOARD_CS_HINT =
|
||||||
|
'使用问题、提现问题等随时可联系【杜康好客】客服';
|
||||||
|
|
||||||
type StoreCategoryNode = {
|
type StoreCategoryNode = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,6 +62,7 @@ type StoreCategoryNode = {
|
|||||||
|
|
||||||
type FieldErrors = {
|
type FieldErrors = {
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
contactPhone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -98,6 +104,10 @@ export default function StoreCreatePage() {
|
|||||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [csAdded, setCsAdded] = useState(false);
|
||||||
|
const [csQrUrl, setCsQrUrl] = useState<string | null>(null);
|
||||||
|
const [csHint, setCsHint] = useState(DEFAULT_PARTNER_ONBOARD_CS_HINT);
|
||||||
|
const [csConfigLoading, setCsConfigLoading] = useState(false);
|
||||||
|
|
||||||
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||||
|
|
||||||
@@ -149,6 +159,29 @@ export default function StoreCreatePage() {
|
|||||||
});
|
});
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 4) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setCsConfigLoading(true);
|
||||||
|
void fetchClientConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setCsQrUrl((cfg.partnerOnboardCsQrUrl ?? '').trim() || null);
|
||||||
|
setCsHint((cfg.partnerOnboardCsHint ?? '').trim() || DEFAULT_PARTNER_ONBOARD_CS_HINT);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setCsQrUrl(null);
|
||||||
|
setCsHint(DEFAULT_PARTNER_ONBOARD_CS_HINT);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setCsConfigLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
void fetchPartnerCities()
|
void fetchPartnerCities()
|
||||||
@@ -238,9 +271,17 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
|
|
||||||
if ('phone' in patch) {
|
if ('phone' in patch || 'contactPhone' in patch) {
|
||||||
|
|
||||||
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
setFieldErrors((prev) => ({
|
||||||
|
|
||||||
|
...prev,
|
||||||
|
|
||||||
|
...('phone' in patch ? { phone: undefined } : {}),
|
||||||
|
|
||||||
|
...('contactPhone' in patch ? { contactPhone: undefined } : {}),
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,22 +291,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
function patchEnvPhotoUrl(index: number, url: string) {
|
|
||||||
|
|
||||||
setForm((prev) => ({
|
|
||||||
|
|
||||||
...prev,
|
|
||||||
|
|
||||||
envPhotoUrls: patchEnvPhotoAt(prev.envPhotoUrls, index, url),
|
|
||||||
|
|
||||||
}));
|
|
||||||
|
|
||||||
setSubmitError('');
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function bindRegionSelection(codes: string[]) {
|
function bindRegionSelection(codes: string[]) {
|
||||||
|
|
||||||
const binding = resolveRegionBinding(codes, cities);
|
const binding = resolveRegionBinding(codes, cities);
|
||||||
@@ -332,7 +357,9 @@ export default function StoreCreatePage() {
|
|||||||
const msg = validateStoreStep3(form);
|
const msg = validateStoreStep3(form);
|
||||||
if (msg) {
|
if (msg) {
|
||||||
if (isPhoneValidationMessage(msg)) {
|
if (isPhoneValidationMessage(msg)) {
|
||||||
setFieldErrors({ phone: msg });
|
setFieldErrors(
|
||||||
|
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
|
||||||
|
);
|
||||||
reportStepError(msg);
|
reportStepError(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,12 +375,23 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
async function submit(skipPackages = false) {
|
async function submit(skipPackages = false) {
|
||||||
|
const qr = (csQrUrl ?? '').trim();
|
||||||
|
if (!qr) {
|
||||||
|
reportFormError('客服二维码暂未配置,请联系总部');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!csAdded) {
|
||||||
|
reportFormError('请先勾选「我已添加【杜康好客】客服」');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const msg = validateStoreStep3(form);
|
const msg = validateStoreStep3(form);
|
||||||
|
|
||||||
if (msg) {
|
if (msg) {
|
||||||
if (isPhoneValidationMessage(msg)) {
|
if (isPhoneValidationMessage(msg)) {
|
||||||
setFieldErrors({ phone: msg });
|
setFieldErrors(
|
||||||
|
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reportFormError(msg);
|
reportFormError(msg);
|
||||||
@@ -440,7 +478,11 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const envPhotoUrls = Array.from(
|
const envPhotoUrls = Array.from(
|
||||||
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
||||||
).slice(0, 3);
|
).slice(0, 20);
|
||||||
|
|
||||||
|
const contractUrls = Array.from(
|
||||||
|
new Set((form.contractUrls ?? []).map((u) => u.trim()).filter(Boolean)),
|
||||||
|
).slice(0, MAX_CONTRACT_COUNT);
|
||||||
|
|
||||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||||
|
|
||||||
@@ -458,6 +500,8 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
|
|
||||||
|
contactPhone: form.contactPhone.trim() || form.phone.trim(),
|
||||||
|
|
||||||
district: form.district.trim(),
|
district: form.district.trim(),
|
||||||
|
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
@@ -488,7 +532,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
|
|
||||||
contractUrl: form.contractUrl.trim() || undefined,
|
contractUrls: contractUrls.length ? contractUrls : undefined,
|
||||||
|
|
||||||
bankAccountName: form.bankAccountName.trim(),
|
bankAccountName: form.bankAccountName.trim(),
|
||||||
|
|
||||||
@@ -539,6 +583,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
|
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
|
||||||
|
|
||||||
|
const canSubmitOnboard = !!csQrUrl?.trim() && csAdded && !submitting && !csConfigLoading;
|
||||||
const nextDisabled = submitting;
|
const nextDisabled = submitting;
|
||||||
|
|
||||||
|
|
||||||
@@ -957,46 +1002,15 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
<section className="partner-form-card">
|
<section className="partner-form-card">
|
||||||
|
|
||||||
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
|
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
|
||||||
|
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 {MIN_ENV_PHOTO_COUNT} 张,支持批量上传,最多 20 张</p>
|
||||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 {MIN_ENV_PHOTO_COUNT} 张,展示店内整洁环境</p>
|
<MultiOssUploadField
|
||||||
|
bizType="STORE_ENV"
|
||||||
<div className="partner-upload-grid">
|
maxCount={20}
|
||||||
|
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
||||||
{form.envPhotoUrls.map((url, index) => (
|
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
||||||
|
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`}
|
||||||
<OssUploadField
|
/>
|
||||||
|
|
||||||
key={index}
|
|
||||||
|
|
||||||
compact
|
|
||||||
|
|
||||||
bizType="STORE_ENV"
|
|
||||||
|
|
||||||
mediaType="IMAGE"
|
|
||||||
|
|
||||||
value={url}
|
|
||||||
|
|
||||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
|
||||||
|
|
||||||
/>
|
|
||||||
|
|
||||||
))}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-btn-outline"
|
|
||||||
style={{ width: '100%', marginTop: 12 }}
|
|
||||||
onClick={() =>
|
|
||||||
setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
添加环境照片
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
@@ -1005,9 +1019,11 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>
|
||||||
|
拍照上传签约协议首页与盖章页,支持多张,最多 {MAX_CONTRACT_COUNT} 个
|
||||||
|
</p>
|
||||||
|
|
||||||
<OssUploadField
|
<MultiOssUploadField
|
||||||
|
|
||||||
bizType="STORE_CONTRACT"
|
bizType="STORE_CONTRACT"
|
||||||
|
|
||||||
@@ -1015,11 +1031,15 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
accept="image/*,.pdf"
|
accept="image/*,.pdf"
|
||||||
|
|
||||||
value={form.contractUrl}
|
unit="个"
|
||||||
|
|
||||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
maxCount={MAX_CONTRACT_COUNT}
|
||||||
|
|
||||||
label="上传合同副本"
|
value={form.contractUrls ?? []}
|
||||||
|
|
||||||
|
onChange={(urls) => patchForm({ contractUrls: urls })}
|
||||||
|
|
||||||
|
label={`批量上传合同(${(form.contractUrls ?? []).length}/${MAX_CONTRACT_COUNT})`}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1137,7 +1157,43 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
|
||||||
该手机号将作为门店端登录账号。
|
老板手机号,作为门店端主账号登录凭证。
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
|
||||||
|
<label>联系电话 <span className="text-primary">*</span></label>
|
||||||
|
|
||||||
|
<div className="partner-field-input">
|
||||||
|
|
||||||
|
<span className="material-symbols-outlined">phone_in_talk</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
|
||||||
|
type="tel"
|
||||||
|
|
||||||
|
placeholder="手机号或座机,如 0379-8888888"
|
||||||
|
|
||||||
|
value={form.contactPhone}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ contactPhone: e.target.value })}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fieldErrors.contactPhone && (
|
||||||
|
|
||||||
|
<p className="partner-field-error" role="alert">{fieldErrors.contactPhone}</p>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
|
||||||
|
用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机(如 0379-8888888)。
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -1150,6 +1206,37 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<>
|
<>
|
||||||
|
<section className="partner-form-card partner-onboard-cs-card">
|
||||||
|
<div className="partner-section-title">
|
||||||
|
<div className="partner-section-bar" />
|
||||||
|
<h2 className="headline-md">添加客服</h2>
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 16, lineHeight: 1.5 }}>
|
||||||
|
{csHint}
|
||||||
|
</p>
|
||||||
|
{csConfigLoading ? (
|
||||||
|
<p className="body-md text-muted">加载客服二维码…</p>
|
||||||
|
) : csQrUrl ? (
|
||||||
|
<img
|
||||||
|
className="partner-onboard-cs-qr"
|
||||||
|
src={csQrUrl}
|
||||||
|
alt="杜康好客企微客服二维码"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="partner-form-error" role="alert">
|
||||||
|
客服二维码暂未配置,请联系总部
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="partner-onboard-cs-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={csAdded}
|
||||||
|
disabled={!csQrUrl || submitting}
|
||||||
|
onChange={(e) => setCsAdded(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>我已添加【杜康好客】客服</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
<section className="partner-form-card">
|
<section className="partner-form-card">
|
||||||
<div className="partner-section-title">
|
<div className="partner-section-title">
|
||||||
<div className="partner-section-bar" />
|
<div className="partner-section-bar" />
|
||||||
@@ -1195,13 +1282,21 @@ export default function StoreCreatePage() {
|
|||||||
) : (
|
) : (
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<button type="button" className="partner-btn-outline" onClick={() => void submit(true)} disabled={submitting}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
onClick={() => void submit(true)}
|
||||||
|
disabled={!canSubmitOnboard}
|
||||||
|
>
|
||||||
跳过
|
跳过
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void submit(false)} disabled={submitting}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
onClick={() => void submit(false)}
|
||||||
|
disabled={!canSubmitOnboard}
|
||||||
|
>
|
||||||
{submitting ? '提交中…' : '提交'}
|
{submitting ? '提交中…' : '提交'}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { request } from '../lib/api';
|
|||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
|
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray } from '../lib/storeDraft';
|
||||||
import OssUploadField from '../components/OssUploadField';
|
import OssUploadField from '../components/OssUploadField';
|
||||||
|
import MultiOssUploadField from '../components/MultiOssUploadField';
|
||||||
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
@@ -42,7 +43,8 @@ export default function StoreDetailPage() {
|
|||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
loginPhone: '',
|
||||||
|
contactPhone: '',
|
||||||
address: '',
|
address: '',
|
||||||
intro: '',
|
intro: '',
|
||||||
benefitUsageRule: '',
|
benefitUsageRule: '',
|
||||||
@@ -63,7 +65,8 @@ export default function StoreDetailPage() {
|
|||||||
setStore(data);
|
setStore(data);
|
||||||
setForm({
|
setForm({
|
||||||
name: String(data.name || ''),
|
name: String(data.name || ''),
|
||||||
phone: String(data.phone || ''),
|
loginPhone: String(data.phone || ''),
|
||||||
|
contactPhone: String(data.contactPhone || data.phone || ''),
|
||||||
address: String(data.address || ''),
|
address: String(data.address || ''),
|
||||||
intro: String(data.intro || ''),
|
intro: String(data.intro || ''),
|
||||||
benefitUsageRule: (() => {
|
benefitUsageRule: (() => {
|
||||||
@@ -163,7 +166,7 @@ export default function StoreDetailPage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
phone: form.phone.trim(),
|
contactPhone: form.contactPhone.trim(),
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
intro: form.intro.trim(),
|
intro: form.intro.trim(),
|
||||||
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
||||||
@@ -282,7 +285,7 @@ export default function StoreDetailPage() {
|
|||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>门店套餐</h3>
|
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>门店套餐</h3>
|
||||||
<button type="button" className="partner-btn-outline" onClick={() => navigate(`/stores/${id}/packages`)}>
|
<button type="button" className="partner-btn-outline partner-btn-outline--compact" onClick={() => navigate(`/stores/${id}/packages`)}>
|
||||||
编辑套餐
|
编辑套餐
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,12 +348,31 @@ export default function StoreDetailPage() {
|
|||||||
<label>门店名称</label>
|
<label>门店名称</label>
|
||||||
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="partner-field">
|
||||||
|
<label>门店登录手机号</label>
|
||||||
|
<div className="partner-field-input">
|
||||||
|
<span className="material-symbols-outlined">lock</span>
|
||||||
|
<input disabled type="tel" value={form.loginPhone} readOnly />
|
||||||
|
</div>
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
老板主账号,用于门店端登录;如需变更请联系总部。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>联系电话</label>
|
<label>联系电话</label>
|
||||||
<div className="partner-field-input">
|
<div className="partner-field-input">
|
||||||
<span className="material-symbols-outlined">call</span>
|
<span className="material-symbols-outlined">phone_in_talk</span>
|
||||||
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
<input
|
||||||
|
disabled={readOnly}
|
||||||
|
type="tel"
|
||||||
|
placeholder="手机号或座机,如 0379-8888888"
|
||||||
|
value={form.contactPhone}
|
||||||
|
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
店长或对外展示号码,用户端拨号使用此号码,支持座机。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店地址</label>
|
<label>门店地址</label>
|
||||||
@@ -409,26 +431,13 @@ export default function StoreDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{canMutate && !readOnly ? (
|
{canMutate && !readOnly ? (
|
||||||
<>
|
<>
|
||||||
<div className="partner-upload-grid">
|
<MultiOssUploadField
|
||||||
{envPhotoUrls.map((url, index) => (
|
bizType="STORE_ENV"
|
||||||
<OssUploadField
|
maxCount={20}
|
||||||
key={index}
|
value={uniqueEnvUrls(envPhotoUrls)}
|
||||||
compact
|
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
|
||||||
bizType="STORE_ENV"
|
label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20)`}
|
||||||
mediaType="IMAGE"
|
/>
|
||||||
value={url}
|
|
||||||
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-btn-outline"
|
|
||||||
style={{ width: '100%', marginTop: 12 }}
|
|
||||||
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
|
|
||||||
>
|
|
||||||
添加环境照片
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-btn-outline"
|
className="partner-btn-outline"
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAcce
|
|||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
storeAuditPillClass,
|
storeListBadge,
|
||||||
storeStatusLabel,
|
|
||||||
storeStatusPillClass,
|
|
||||||
type StoreStatusValue,
|
type StoreStatusValue,
|
||||||
} from '../lib/storeStatus';
|
} from '../lib/storeStatus';
|
||||||
import { usePartnerPageView } from '../lib/usePageView';
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
@@ -20,10 +18,10 @@ type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
|
|||||||
const FILTERS: { key: StatusFilter; label: string }[] = [
|
const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||||
{ key: 'ALL', label: '全部' },
|
{ key: 'ALL', label: '全部' },
|
||||||
{ key: 'OPEN', label: '营业中' },
|
{ key: 'OPEN', label: '营业中' },
|
||||||
{ key: 'PAUSED', label: '暂时闭店' },
|
{ key: 'PAUSED', label: '临时闭店' },
|
||||||
{ key: 'PENDING_AUDIT', label: '待审核' },
|
{ key: 'PENDING_AUDIT', label: '待审核' },
|
||||||
{ key: 'REJECTED', label: '已驳回' },
|
{ key: 'REJECTED', label: '已驳回' },
|
||||||
{ key: 'CLOSED', label: '关闭' },
|
{ key: 'CLOSED', label: '永久闭店' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StoreListPage() {
|
export default function StoreListPage() {
|
||||||
@@ -46,7 +44,10 @@ export default function StoreListPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
if (!isLoggedIn()) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
void loadStores();
|
void loadStores();
|
||||||
}, [navigate, loadStores]);
|
}, [navigate, loadStores]);
|
||||||
|
|
||||||
@@ -54,20 +55,28 @@ export default function StoreListPage() {
|
|||||||
document.title = canMutate ? '门店管理' : '我的门店';
|
document.title = canMutate ? '门店管理' : '我的门店';
|
||||||
}, [canMutate]);
|
}, [canMutate]);
|
||||||
|
|
||||||
const filtered = useMemo(() => stores.filter((s) => {
|
const filtered = useMemo(
|
||||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
() =>
|
||||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
stores.filter((s) => {
|
||||||
const status = String(s.status).toUpperCase();
|
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||||
let matchStatus = true;
|
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||||
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
|
const status = String(s.status).toUpperCase();
|
||||||
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
let matchStatus = true;
|
||||||
else if (filter !== 'ALL') matchStatus = status === filter;
|
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
|
||||||
return matchQ && matchStatus;
|
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
||||||
}), [stores, q, filter]);
|
else if (filter !== 'ALL') matchStatus = status === filter;
|
||||||
|
return matchQ && matchStatus;
|
||||||
|
}),
|
||||||
|
[stores, q, filter],
|
||||||
|
);
|
||||||
|
|
||||||
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
|
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
|
||||||
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
||||||
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
|
setError(
|
||||||
|
auditStatus === 'REJECTED'
|
||||||
|
? '门店审核未通过,请查看驳回原因并修改后重新提交'
|
||||||
|
: '门店尚在总部审核中,通过后方可开门',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (next === 'CLOSED') {
|
if (next === 'CLOSED') {
|
||||||
@@ -82,7 +91,8 @@ export default function StoreListPage() {
|
|||||||
body: JSON.stringify({ status: next }),
|
body: JSON.stringify({ status: next }),
|
||||||
});
|
});
|
||||||
await loadStores();
|
await loadStores();
|
||||||
if (next === 'OPEN') toastSuccess('开店成功');
|
if (next === 'OPEN') toastSuccess('已营业');
|
||||||
|
if (next === 'PAUSED') toastSuccess('已临时闭店');
|
||||||
} catch {
|
} catch {
|
||||||
/* request 已 toast */
|
/* request 已 toast */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,6 +112,7 @@ export default function StoreListPage() {
|
|||||||
body: JSON.stringify({ status: 'CLOSED' }),
|
body: JSON.stringify({ status: 'CLOSED' }),
|
||||||
});
|
});
|
||||||
await loadStores();
|
await loadStores();
|
||||||
|
toastSuccess('已永久闭店');
|
||||||
} catch {
|
} catch {
|
||||||
/* request 已 toast */
|
/* request 已 toast */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -111,29 +122,43 @@ export default function StoreListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
||||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
{error && (
|
||||||
|
<p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="partner-sticky-filter">
|
<div className="partner-sticky-filter">
|
||||||
<div className="partner-search">
|
<div className="partner-search">
|
||||||
<span className="material-symbols-outlined">search</span>
|
<span className="material-symbols-outlined">search</span>
|
||||||
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-chips">
|
<div className="partner-store-filter-row">
|
||||||
{FILTERS.map((f) => (
|
<label className="partner-store-filter-label" htmlFor="partner-store-status-filter">
|
||||||
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
|
状态
|
||||||
{f.label}
|
</label>
|
||||||
</button>
|
<select
|
||||||
))}
|
id="partner-store-status-filter"
|
||||||
|
className="partner-store-filter-select"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value as StatusFilter)}
|
||||||
|
>
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<option key={f.key} value={f.key}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<Link to="/stores/new" className="partner-fab-link">
|
<Link to="/stores/new" className="partner-fab-link">
|
||||||
<button type="button" className="partner-btn-primary">
|
<button type="button" className="partner-btn-primary">
|
||||||
<span className="material-symbols-outlined">add_business</span>
|
<span className="material-symbols-outlined">add_business</span>
|
||||||
录入新门店
|
录入新门店
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||||
@@ -145,87 +170,114 @@ export default function StoreListPage() {
|
|||||||
const dim = currentStatus === 'CLOSED';
|
const dim = currentStatus === 'CLOSED';
|
||||||
const storeName = String(s.name || '未命名门店');
|
const storeName = String(s.name || '未命名门店');
|
||||||
const busy = updatingId === storeId;
|
const busy = updatingId === storeId;
|
||||||
const canOpen = canPartnerOpenStore(auditStatus);
|
const badge = storeListBadge(s);
|
||||||
|
const switchOn = currentStatus === 'OPEN';
|
||||||
|
const switchDisabled =
|
||||||
|
busy ||
|
||||||
|
currentStatus === 'CLOSED' ||
|
||||||
|
auditStatus === 'PENDING' ||
|
||||||
|
auditStatus === 'REJECTED';
|
||||||
return (
|
return (
|
||||||
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||||
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
|
<Link
|
||||||
|
to={`/stores/${storeId}`}
|
||||||
|
className="partner-store-card-hit"
|
||||||
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
<div className="partner-store-card-header">
|
<div className="partner-store-card-header">
|
||||||
<div>
|
<div className="partner-store-card-main">
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>门店名称</p>
|
<p className="label-md text-muted" style={{ marginBottom: 2 }}>
|
||||||
|
门店名称
|
||||||
|
</p>
|
||||||
<h3 className="headline-md">{storeName}</h3>
|
<h3 className="headline-md">{storeName}</h3>
|
||||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
<p className="label-md text-muted" style={{ marginTop: 4 }}>
|
||||||
{auditStatus !== 'APPROVED' && (
|
{String(s.address || s.district || '')}
|
||||||
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}>
|
</p>
|
||||||
{storeAuditLabel(auditStatus)}
|
{auditStatus === 'REJECTED' && s.rejectReason ? (
|
||||||
{auditStatus === 'REJECTED' && s.rejectReason ? `:${String(s.rejectReason)}` : ''}
|
<p
|
||||||
|
className="label-md"
|
||||||
|
style={{ marginTop: 8, color: 'var(--color-heritage-red)' }}
|
||||||
|
>
|
||||||
|
{storeAuditLabel(auditStatus)}:{String(s.rejectReason)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
|
||||||
{auditStatus !== 'APPROVED' ? (
|
|
||||||
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
|
|
||||||
{storeAuditLabel(auditStatus)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
|
|
||||||
{storeStatusLabel(currentStatus)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className={`partner-status-pill partner-store-card-badge ${badge.pillClass}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{canMutate && (
|
{canMutate ? (
|
||||||
<div className="partner-store-card-actions">
|
<div className="partner-store-card-actions partner-store-card-actions--v3416">
|
||||||
<button
|
{currentStatus !== 'CLOSED' ? (
|
||||||
type="button"
|
<>
|
||||||
className="btn btn-outline"
|
<label
|
||||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
className={`partner-store-switch${switchDisabled ? ' partner-store-switch--disabled' : ''}`}
|
||||||
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
|
>
|
||||||
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
暂时闭店
|
role="switch"
|
||||||
</button>
|
checked={switchOn}
|
||||||
<button
|
disabled={switchDisabled}
|
||||||
type="button"
|
aria-label={switchOn ? '营业中,点击临时闭店' : '临时闭店,点击营业'}
|
||||||
className="btn btn-outline"
|
onChange={() => {
|
||||||
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
|
const next: StoreStatusValue = switchOn ? 'PAUSED' : 'OPEN';
|
||||||
disabled={busy || currentStatus === 'CLOSED'}
|
void updateStatus(storeId, next, auditStatus);
|
||||||
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
}}
|
||||||
>
|
/>
|
||||||
关闭
|
<span className="partner-store-switch-track" aria-hidden />
|
||||||
</button>
|
<span className="partner-store-switch-text">
|
||||||
{currentStatus === 'PAUSED' && (
|
{switchOn ? '开启(营业中)' : '关闭(临时闭店)'}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
</label>
|
||||||
className="btn btn-outline"
|
<button
|
||||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
type="button"
|
||||||
disabled={busy || !canOpen}
|
className="partner-store-close-btn"
|
||||||
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
|
disabled={busy}
|
||||||
|
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
||||||
|
>
|
||||||
|
永久闭店
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
to={`/stores/${storeId}`}
|
||||||
|
className="partner-menu-icon"
|
||||||
|
style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
{canOpen ? '开门营业' : '待审核通过'}
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||||||
</button>
|
edit
|
||||||
)}
|
</span>
|
||||||
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
|
</Link>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
</div>
|
||||||
</Link>
|
) : null}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{closeTarget && (
|
{closeTarget && (
|
||||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
|
<div
|
||||||
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
className="partner-ship-modal-backdrop"
|
||||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
role="presentation"
|
||||||
|
onClick={() => setCloseTarget(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="partner-ship-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="headline-md" style={{ marginBottom: 8 }}>
|
||||||
|
确认永久闭店?
|
||||||
|
</h3>
|
||||||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||||
关闭后不可恢复营业,确认关闭该门店?
|
永久闭店后不可再开门营业,确认关闭该门店?
|
||||||
</p>
|
</p>
|
||||||
<div className="partner-ship-actions">
|
<div className="partner-ship-actions">
|
||||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||||
确认关闭
|
确认永久闭店
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||||
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import StorePackagesForm from '../components/StorePackagesForm';
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
@@ -30,7 +31,18 @@ export default function StorePackagesPage() {
|
|||||||
: data.live?.length
|
: data.live?.length
|
||||||
? data.live
|
? data.live
|
||||||
: [emptyPackage()];
|
: [emptyPackage()];
|
||||||
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
setItems(
|
||||||
|
base.map((p, i) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
setPending(data.pendingRequest ?? null);
|
setPending(data.pendingRequest ?? null);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
|||||||
@@ -304,6 +304,30 @@ html {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 卡片标题旁的紧凑型描边按钮:不拉伸、缩小尺寸、贴右对齐 */
|
||||||
|
.partner-btn-outline--compact {
|
||||||
|
flex: none;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--color-outline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.partner-btn-outline--compact:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.partner-btn-outline--compact:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
.partner-btn-wechat {
|
.partner-btn-wechat {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -1960,6 +1984,134 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-store-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-filter-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-filter-select {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--color-outline-variant);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-container-low);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-ink-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-filter-select:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(166, 29, 36, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-card-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-card-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-card-actions--v3416 {
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-card-actions--v3416 > .partner-store-switch {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-card-actions--v3416 > .partner-store-close-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-card-actions--v3416 > .partner-menu-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-switch input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-switch-track {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface-container-highest);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-switch-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-switch input:checked + .partner-store-switch-track {
|
||||||
|
background: var(--color-success-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-switch input:checked + .partner-store-switch-track::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-switch--disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-close-btn {
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
border: 1px solid rgba(166, 29, 36, 0.35);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-store-close-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.partner-store-card {
|
.partner-store-card {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -2061,6 +2213,39 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
margin: 0 var(--space-page) var(--space-md);
|
margin: 0 var(--space-page) var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-onboard-cs-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-onboard-cs-qr {
|
||||||
|
display: block;
|
||||||
|
width: min(220px, 70vw);
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-surface-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-onboard-cs-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-ink-black);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-onboard-cs-check input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Store detail ── */
|
/* ── Store detail ── */
|
||||||
.partner-detail-page {
|
.partner-detail-page {
|
||||||
padding-bottom: 96px;
|
padding-bottom: 96px;
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
|
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"],
|
||||||
|
"@dukang/domain": ["../../packages/domain/src/index.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||||
|
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5175,
|
port: 5175,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3000',
|
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
import {
|
||||||
|
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
||||||
|
STORE_PACKAGE_MAX_COUNT,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
|
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
|
||||||
import { uploadFileToOss } from '../lib/upload';
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
|
||||||
@@ -14,14 +17,32 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
async function pickPackageImage(index: number, file?: File | null) {
|
async function pickPackageImages(pkgIndex: number, fileList?: FileList | null) {
|
||||||
if (!file || disabled) return;
|
if (!fileList?.length || disabled) return;
|
||||||
setUploadingIndex(index);
|
const current = items[pkgIndex];
|
||||||
|
const existing =
|
||||||
|
Array.isArray(current.imageUrls) && current.imageUrls.length > 0
|
||||||
|
? current.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
|
||||||
|
: current.imageUrl
|
||||||
|
? [String(current.imageUrl)]
|
||||||
|
: [];
|
||||||
|
const room = Math.max(0, STORE_PACKAGE_IMAGE_MAX_COUNT - existing.length);
|
||||||
|
const files = Array.from(fileList).slice(0, room);
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
setUploadingIndex(pkgIndex);
|
||||||
try {
|
try {
|
||||||
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
|
const appended: string[] = [];
|
||||||
updateAt(index, { imageUrl: result.url });
|
for (const file of files) {
|
||||||
|
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
|
||||||
|
appended.push(result.url);
|
||||||
|
}
|
||||||
|
const next = [...existing, ...appended];
|
||||||
|
updateAt(pkgIndex, { imageUrls: next, imageUrl: next[0] ?? '' });
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingIndex(null);
|
setUploadingIndex(null);
|
||||||
|
const input = fileRefs.current[pkgIndex];
|
||||||
|
if (input) input.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +79,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
{list.map((item, index) => {
|
{list.map((item, index) => {
|
||||||
const isCollapsed = !!collapsed[index];
|
const isCollapsed = !!collapsed[index];
|
||||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||||
|
const filled =
|
||||||
|
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
|
||||||
|
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
|
||||||
|
: item.imageUrl
|
||||||
|
? [String(item.imageUrl)]
|
||||||
|
: [];
|
||||||
return (
|
return (
|
||||||
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
|
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
|
||||||
<div className="shop-packages-card-head">
|
<div className="shop-packages-card-head">
|
||||||
@@ -83,7 +110,7 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<>
|
<>
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">套餐名称 *</span>
|
<span className="shop-packages-label">套餐名称</span>
|
||||||
<input
|
<input
|
||||||
className="shop-packages-input"
|
className="shop-packages-input"
|
||||||
placeholder="如:套餐A"
|
placeholder="如:套餐A"
|
||||||
@@ -94,12 +121,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">价格(元) *</span>
|
<span className="shop-packages-label">价格(元)</span>
|
||||||
<input
|
<input
|
||||||
className="shop-packages-input"
|
className="shop-packages-input"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={0.01}
|
step="0.01"
|
||||||
placeholder="198"
|
placeholder="198"
|
||||||
value={item.price}
|
value={item.price}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -108,10 +135,10 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">菜品 *</span>
|
<span className="shop-packages-label">菜品</span>
|
||||||
<textarea
|
<textarea
|
||||||
className="shop-packages-textarea"
|
className="shop-packages-input"
|
||||||
rows={3}
|
rows={2}
|
||||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||||
value={item.dishes}
|
value={item.dishes}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -121,8 +148,9 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
|
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">使用时间</span>
|
<span className="shop-packages-label">使用时间</span>
|
||||||
<input
|
<textarea
|
||||||
className="shop-packages-input"
|
className="shop-packages-input"
|
||||||
|
rows={2}
|
||||||
placeholder="节假日除外"
|
placeholder="节假日除外"
|
||||||
value={item.usableTime || ''}
|
value={item.usableTime || ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -130,14 +158,35 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="shop-packages-field">
|
<div className="shop-packages-field">
|
||||||
<span className="shop-packages-label">套餐图片</span>
|
<span className="shop-packages-label">
|
||||||
{item.imageUrl ? (
|
套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量)
|
||||||
<img
|
</span>
|
||||||
src={item.imageUrl}
|
{filled.length > 0 ? (
|
||||||
alt=""
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||||
style={{ width: '100%', maxHeight: 160, objectFit: 'cover', borderRadius: 8, marginBottom: 8 }}
|
{filled.map((url, imgIndex) => (
|
||||||
/>
|
<div key={`${url}-${imgIndex}`} style={{ position: 'relative', width: 88 }}>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
{!disabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-packages-remove"
|
||||||
|
style={{ position: 'absolute', top: 2, right: 2, margin: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
const next = filled.filter((_, i) => i !== imgIndex);
|
||||||
|
updateAt(index, { imageUrls: next, imageUrl: next[0] ?? '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<input
|
<input
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -145,25 +194,29 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
}}
|
}}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
multiple
|
||||||
hidden
|
hidden
|
||||||
disabled={disabled}
|
disabled={disabled || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||||
onChange={(e) => void pickPackageImage(index, e.target.files?.[0])}
|
onChange={(e) => void pickPackageImages(index, e.target.files)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-packages-add"
|
className="shop-packages-add"
|
||||||
style={{ marginTop: 0 }}
|
style={{ marginTop: 0 }}
|
||||||
disabled={disabled || uploadingIndex === index}
|
disabled={disabled || uploadingIndex === index || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||||
onClick={() => fileRefs.current[index]?.click()}
|
onClick={() => fileRefs.current[index]?.click()}
|
||||||
>
|
>
|
||||||
{uploadingIndex === index ? '上传中…' : item.imageUrl ? '更换图片' : '上传图片'}
|
{uploadingIndex === index
|
||||||
|
? '上传中…'
|
||||||
|
: `批量上传(${filled.length}/${STORE_PACKAGE_IMAGE_MAX_COUNT})`}
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">其他说明</span>
|
<span className="shop-packages-label">其他说明</span>
|
||||||
<input
|
<textarea
|
||||||
className="shop-packages-input"
|
className="shop-packages-input"
|
||||||
|
rows={2}
|
||||||
placeholder="不可叠加"
|
placeholder="不可叠加"
|
||||||
value={item.otherNotes || ''}
|
value={item.otherNotes || ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
import {
|
||||||
|
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
||||||
|
STORE_PACKAGE_MAX_COUNT,
|
||||||
|
normalizeStorePackageImageUrls,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
export type PackageFormItem = StorePackageItemDto;
|
export type PackageFormItem = StorePackageItemDto;
|
||||||
|
|
||||||
@@ -11,24 +15,34 @@ export function emptyPackage(index = 0): PackageFormItem {
|
|||||||
usableTime: '',
|
usableTime: '',
|
||||||
otherNotes: '',
|
otherNotes: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
imageUrls: [],
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||||
return raw
|
return raw
|
||||||
.map((item, index) => ({
|
.map((item, index) => {
|
||||||
name: item.name.trim(),
|
const imageUrls = normalizeStorePackageImageUrls(item);
|
||||||
price: item.price.trim(),
|
return {
|
||||||
dishes: item.dishes.trim(),
|
name: item.name.trim(),
|
||||||
usableTime: item.usableTime?.trim() || '',
|
price: item.price.trim(),
|
||||||
otherNotes: item.otherNotes?.trim() || '',
|
dishes: item.dishes.trim(),
|
||||||
imageUrl: item.imageUrl?.trim() || '',
|
usableTime: item.usableTime?.trim() || '',
|
||||||
sortOrder: index,
|
otherNotes: item.otherNotes?.trim() || '',
|
||||||
}))
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
})
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
|
item.name ||
|
||||||
|
item.price ||
|
||||||
|
item.dishes ||
|
||||||
|
item.usableTime ||
|
||||||
|
item.otherNotes ||
|
||||||
|
item.imageUrls.length > 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +57,15 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
|
|||||||
const price = Number(item.price);
|
const price = Number(item.price);
|
||||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||||
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||||
|
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
||||||
|
return `第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatPackagePrice(price: string | number) {
|
||||||
|
const n = typeof price === 'number' ? price : Number(price);
|
||||||
|
if (!Number.isFinite(n)) return String(price);
|
||||||
|
return n % 1 === 0 ? String(n) : n.toFixed(2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||||
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import ShopPackagesForm from '../components/ShopPackagesForm';
|
import ShopPackagesForm from '../components/ShopPackagesForm';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
@@ -30,7 +31,18 @@ export default function PackagesPage() {
|
|||||||
: data.live?.length
|
: data.live?.length
|
||||||
? data.live
|
? data.live
|
||||||
: [emptyPackage()];
|
: [emptyPackage()];
|
||||||
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
setItems(
|
||||||
|
base.map((p, i) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
setPending(data.pendingRequest ?? null);
|
setPending(data.pendingRequest ?? null);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: { '/api': 'http://localhost:3000' },
|
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -12,8 +12,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:3000',
|
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -16,7 +16,7 @@ export default defineConfig(async () => ({
|
|||||||
plugins: ['@tarojs/plugin-html'],
|
plugins: ['@tarojs/plugin-html'],
|
||||||
defineConstants: {
|
defineConstants: {
|
||||||
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
|
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
|
||||||
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3000'),
|
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3010'),
|
||||||
},
|
},
|
||||||
copy: {
|
copy: {
|
||||||
patterns: [],
|
patterns: [],
|
||||||
@@ -44,7 +44,7 @@ export default defineConfig(async () => ({
|
|||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.VITE_API_TARGET ?? 'http://localhost:3000',
|
target: process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function resolveApiBase(): string {
|
|||||||
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
||||||
? TARO_APP_API_ORIGIN
|
? TARO_APP_API_ORIGIN
|
||||||
: process.env.TARO_ENV === 'h5'
|
: process.env.TARO_ENV === 'h5'
|
||||||
? 'http://localhost:3000'
|
? 'http://localhost:3010'
|
||||||
: '';
|
: '';
|
||||||
if (origin) {
|
if (origin) {
|
||||||
return `${origin.replace(/\/$/, '')}/api/v1`;
|
return `${origin.replace(/\/$/, '')}/api/v1`;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pnpm build:mini-user:weapp
|
|||||||
|
|
||||||
| 环节 | 文件 | 说明 |
|
| 环节 | 文件 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3010`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
||||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||||
|
|
||||||
### 微信登录 `invalid code`
|
### 微信登录 `invalid code`
|
||||||
@@ -33,7 +33,7 @@ pnpm build:mini-user:weapp
|
|||||||
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
||||||
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
||||||
|
|
||||||
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock:
|
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3010`),并开启 Mock:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 终端 1
|
# 终端 1
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const isDevMode =
|
|||||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||||
const API_ORIGIN =
|
const API_ORIGIN =
|
||||||
process.env.VITE_API_TARGET ??
|
process.env.VITE_API_TARGET ??
|
||||||
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
|
(isDevMode ? 'http://localhost:3010' : 'https://api.dukanghaoke.com');
|
||||||
|
|
||||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function resolveApiBase(): string {
|
|||||||
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
||||||
? TARO_APP_API_ORIGIN
|
? TARO_APP_API_ORIGIN
|
||||||
: process.env.TARO_ENV === 'h5'
|
: process.env.TARO_ENV === 'h5'
|
||||||
? 'http://localhost:3000'
|
? 'http://localhost:3010'
|
||||||
: '';
|
: '';
|
||||||
if (origin) {
|
if (origin) {
|
||||||
return `${origin.replace(/\/$/, '')}/api/v1`;
|
return `${origin.replace(/\/$/, '')}/api/v1`;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
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 {
|
export function normalizePhoneInput(value: string): string {
|
||||||
return value.replace(/\D/g, '').slice(0, 11);
|
return value.replace(/\D/g, '').slice(0, 11);
|
||||||
@@ -18,6 +19,41 @@ export function validateMobilePhone(phone: string): { ok: boolean; message?: str
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maskPhone(phone: string) {
|
function normalizeContactPhone(raw: string): string {
|
||||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
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 StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { maskPhone } from '../../lib/phone';
|
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
||||||
import { track } from '../../lib/analytics';
|
import { track } from '../../lib/analytics';
|
||||||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||||||
import {
|
import {
|
||||||
@@ -108,6 +108,13 @@ function formatRedeemTime(input?: string | null) {
|
|||||||
return formatShanghaiDateTime(input);
|
return formatShanghaiDateTime(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPackagePriceYuan(price: string | number) {
|
||||||
|
const n = typeof price === 'number' ? price : Number(price);
|
||||||
|
if (!Number.isFinite(n)) return '0';
|
||||||
|
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||||
|
return n.toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function formatRedeemAmountYuan(amount: number | string) {
|
function formatRedeemAmountYuan(amount: number | string) {
|
||||||
const n = typeof amount === 'number' ? amount : Number(amount);
|
const n = typeof amount === 'number' ? amount : Number(amount);
|
||||||
if (!Number.isFinite(n)) return '0';
|
if (!Number.isFinite(n)) return '0';
|
||||||
@@ -269,7 +276,7 @@ export default function StoreDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
track('store_phone_call', { storeId: store.id });
|
track('store_phone_call', { storeId: store.id });
|
||||||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
Taro.makePhoneCall({ phoneNumber: toDialablePhone(store.phone) }).catch(() => toast('无法拨打电话'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMap() {
|
function openMap() {
|
||||||
@@ -419,7 +426,12 @@ export default function StoreDetailPage() {
|
|||||||
className="store-detail-package-list-item"
|
className="store-detail-package-list-item"
|
||||||
onClick={() => openPackageDetail(index)}
|
onClick={() => openPackageDetail(index)}
|
||||||
>
|
>
|
||||||
<Text className="store-detail-package-list-title">{pkg.name}</Text>
|
<View className="store-detail-package-list-row">
|
||||||
|
<Text className="store-detail-package-list-title">{pkg.name}</Text>
|
||||||
|
<Text className="store-detail-package-list-price">
|
||||||
|
¥{formatPackagePriceYuan(pkg.price)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import Taro, { useLoad, useRouter } from '@tarojs/taro';
|
import Taro, { useLoad, useRouter } from '@tarojs/taro';
|
||||||
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import PageNavBar from '../../components/PageNavBar';
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
|
||||||
type StorePackage = {
|
type StorePackage = {
|
||||||
@@ -12,6 +14,7 @@ type StorePackage = {
|
|||||||
usableTime?: string | null;
|
usableTime?: string | null;
|
||||||
otherNotes?: string | null;
|
otherNotes?: string | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
imageUrls?: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Store = {
|
type Store = {
|
||||||
@@ -97,10 +100,6 @@ export default function StorePackageDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewImage(url: string) {
|
|
||||||
Taro.previewImage({ urls: [url], current: url }).catch(() => toast('无法预览图片'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<PageShell variant="scroll" className="store-package-detail-page">
|
<PageShell variant="scroll" className="store-package-detail-page">
|
||||||
@@ -119,7 +118,7 @@ export default function StorePackageDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = (pkg.imageUrl || '').trim();
|
const imageUrls = normalizeStorePackageImageUrls(pkg);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="scroll" className="store-package-detail-page">
|
<PageShell variant="scroll" className="store-package-detail-page">
|
||||||
@@ -137,15 +136,13 @@ export default function StorePackageDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{imageUrl ? (
|
{imageUrls.length > 0 ? (
|
||||||
<View
|
<View className="store-package-detail-gallery">
|
||||||
className="store-package-detail-photo"
|
<ProductCarousel
|
||||||
onClick={() => previewImage(imageUrl)}
|
images={imageUrls}
|
||||||
>
|
alt={pkg.name}
|
||||||
<Image
|
variant="detail"
|
||||||
className="store-package-detail-photo-image"
|
previewable
|
||||||
src={imageUrl}
|
|
||||||
mode="widthFix"
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -281,13 +281,36 @@
|
|||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-list-title {
|
/* 名称在左可换行;价格始终贴该行最右侧 */
|
||||||
|
.store-detail-package-list-row {
|
||||||
|
/* display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
column-gap: 12px;
|
||||||
|
row-gap: 4px; */
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-list-title {
|
||||||
|
min-width: 0;
|
||||||
|
font-family: var(--font-headline);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-list-price {
|
||||||
|
display: inline;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heritage-red, #a61d24);
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-list-item:active {
|
.store-detail-package-list-item:active {
|
||||||
@@ -315,15 +338,18 @@
|
|||||||
padding: 8px 16px 4px 16px;
|
padding: 8px 16px 4px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 与门店详情套餐列表一致:名称在左可换行;价格始终贴该行最右侧 */
|
||||||
.store-package-detail-title-row {
|
.store-package-detail-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
align-items: flex-end;
|
||||||
gap: 16px;
|
column-gap: 16px;
|
||||||
|
row-gap: 4px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-package-detail-title {
|
.store-package-detail-title {
|
||||||
flex: 1;
|
/* flex: 1 1 10em; */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -334,13 +360,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.store-package-detail-price {
|
.store-package-detail-price {
|
||||||
flex-shrink: 0;
|
/* flex: 0 0 auto; */
|
||||||
font-size: 24px;
|
margin-left: auto;
|
||||||
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.35;
|
line-height: 1.4;
|
||||||
color: #a61d24;
|
|
||||||
color: var(--color-heritage-red, #a61d24);
|
color: var(--color-heritage-red, #a61d24);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-package-detail-store {
|
.store-package-detail-store {
|
||||||
@@ -352,7 +379,7 @@
|
|||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-package-detail-photo {
|
.store-package-detail-gallery {
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -360,10 +387,8 @@
|
|||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-package-detail-photo-image {
|
.store-package-detail-gallery .detail-carousel-wrap {
|
||||||
width: 100%;
|
border-radius: 0;
|
||||||
display: block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-package-detail-content {
|
.store-package-detail-content {
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -4,6 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./src/index.ts",
|
||||||
|
"require": "./dist/index.js",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
|
|||||||
@@ -351,3 +351,4 @@ export function orderTabToStatuses(tab: string): string[] | undefined {
|
|||||||
export * from './city-partner';
|
export * from './city-partner';
|
||||||
export * from './dev-plan';
|
export * from './dev-plan';
|
||||||
export * from './support-ticket';
|
export * from './support-ticket';
|
||||||
|
export * from './phone';
|
||||||
|
|||||||
@@ -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('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 '****';
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** HQ 权限目录(权限分配页勾选源) */
|
/** HQ 权限目录(权限分配页勾选源) */
|
||||||
export const HQ_PERMISSION_CATALOG = [
|
export const HQ_PERMISSION_CATALOG = [
|
||||||
{ key: 'dashboard', label: '概览', group: '业务' },
|
{ key: 'dashboard', label: '概览', group: '业务' },
|
||||||
{ key: 'users', label: '用户管理', group: '业务' },
|
{ key: 'users', label: '用户管理', group: '业务' },
|
||||||
@@ -30,6 +30,7 @@ export const HQ_PERMISSION_CATALOG = [
|
|||||||
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
|
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
|
||||||
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
|
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
|
||||||
{ key: 'system_settings_wechat_mini', label: '微信小程序', group: '系统设置' },
|
{ key: 'system_settings_wechat_mini', label: '微信小程序', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_wechat_mini_share', label: '小程序分享配置', group: '系统设置' },
|
||||||
{ key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' },
|
{ key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' },
|
||||||
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
||||||
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
||||||
@@ -60,6 +61,7 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
|
|||||||
sms: 'system_settings_sms',
|
sms: 'system_settings_sms',
|
||||||
wechat: 'system_settings_wechat',
|
wechat: 'system_settings_wechat',
|
||||||
wechat_mini: 'system_settings_wechat_mini',
|
wechat_mini: 'system_settings_wechat_mini',
|
||||||
|
wechat_mini_share: 'system_settings_wechat_mini_share',
|
||||||
oss: 'system_settings_oss',
|
oss: 'system_settings_oss',
|
||||||
app: 'system_settings_app',
|
app: 'system_settings_app',
|
||||||
deploy: 'system_settings_deploy',
|
deploy: 'system_settings_deploy',
|
||||||
@@ -121,6 +123,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'resources',
|
'resources',
|
||||||
'logs',
|
'logs',
|
||||||
'system_settings_wechat_mini',
|
'system_settings_wechat_mini',
|
||||||
|
'system_settings_wechat_mini_share',
|
||||||
],
|
],
|
||||||
FINANCE: [
|
FINANCE: [
|
||||||
'dashboard',
|
'dashboard',
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ export interface StorePackageItemDto {
|
|||||||
dishes: string;
|
dishes: string;
|
||||||
usableTime?: string | null;
|
usableTime?: string | null;
|
||||||
otherNotes?: string | null;
|
otherNotes?: string | null;
|
||||||
|
/** 首图(兼容旧字段;多图时等于 imageUrls[0]) */
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
/** 套餐图片列表,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
|
||||||
|
imageUrls?: string[] | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,49 @@ export const STORE_PACKAGE_CHANGE_STATUS_LABELS: Record<StorePackageChangeStatus
|
|||||||
|
|
||||||
export const STORE_PACKAGE_MAX_COUNT = 10;
|
export const STORE_PACKAGE_MAX_COUNT = 10;
|
||||||
|
|
||||||
|
/** 单条套餐最多上传图片数 */
|
||||||
|
export const STORE_PACKAGE_IMAGE_MAX_COUNT = 20;
|
||||||
|
|
||||||
|
/** 门店环境照最多张数(总部/合伙人上传) */
|
||||||
|
export const STORE_ENV_PHOTO_MAX_COUNT = 20;
|
||||||
|
|
||||||
|
/** 归一化套餐图片:兼容 imageUrl / imageUrls(含 JSON 字符串),去重后截断上限 */
|
||||||
|
export function normalizeStorePackageImageUrls(input: {
|
||||||
|
imageUrl?: string | null;
|
||||||
|
imageUrls?: unknown;
|
||||||
|
}): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
const push = (raw: unknown) => {
|
||||||
|
const url = String(raw ?? '').trim();
|
||||||
|
if (!url || seen.has(url)) return;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
let list: unknown = input.imageUrls;
|
||||||
|
if (typeof list === 'string') {
|
||||||
|
const trimmed = list.trim();
|
||||||
|
if (trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
list = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
list = trimmed ? [trimmed] : [];
|
||||||
|
}
|
||||||
|
} else if (trimmed) {
|
||||||
|
list = [trimmed];
|
||||||
|
} else {
|
||||||
|
list = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
for (const item of list) push(item);
|
||||||
|
}
|
||||||
|
if (out.length === 0) push(input.imageUrl);
|
||||||
|
return out.slice(0, STORE_PACKAGE_IMAGE_MAX_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorePackagesResponse {
|
export interface StorePackagesResponse {
|
||||||
live: StorePackageViewDto[];
|
live: StorePackageViewDto[];
|
||||||
pendingRequest?: {
|
pendingRequest?: {
|
||||||
@@ -59,6 +105,10 @@ export interface StorePackageAuditAction {
|
|||||||
rejectReason?: string;
|
rejectReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorePackageAuditSummaryDto {
|
||||||
|
pendingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatePackageDisputeRequest {
|
export interface CreatePackageDisputeRequest {
|
||||||
storeId: string;
|
storeId: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export type ClientRuntimeConfig = {
|
|||||||
qualificationDisclosureUrl?: string;
|
qualificationDisclosureUrl?: string;
|
||||||
/** 总部客服电话 */
|
/** 总部客服电话 */
|
||||||
customerServicePhone?: string;
|
customerServicePhone?: string;
|
||||||
|
/** 合伙人入驻:企微客服二维码图片 URL */
|
||||||
|
partnerOnboardCsQrUrl?: string | null;
|
||||||
|
/** 合伙人入驻:企微客服提示文案 */
|
||||||
|
partnerOnboardCsHint?: string | null;
|
||||||
/** 小程序各场景分享文案/图 */
|
/** 小程序各场景分享文案/图 */
|
||||||
share?: MiniShareRuntime;
|
share?: MiniShareRuntime;
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+6
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@ant-design/icons':
|
'@ant-design/icons':
|
||||||
specifier: ^5.5.1
|
specifier: ^5.5.1
|
||||||
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.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':
|
'@dukang/shared-types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared-types
|
version: link:../../packages/shared-types
|
||||||
@@ -78,6 +81,9 @@ importers:
|
|||||||
'@dukang/client-logging':
|
'@dukang/client-logging':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/client-logging
|
version: link:../../packages/client-logging
|
||||||
|
'@dukang/domain':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/domain
|
||||||
'@dukang/shared-types':
|
'@dukang/shared-types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared-types
|
version: link:../../packages/shared-types
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ DATABASE_URL="mysql://root:root@localhost:6016/dukang_haoke"
|
|||||||
REDIS_URL="redis://localhost:6017"
|
REDIS_URL="redis://localhost:6017"
|
||||||
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
|
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
|
||||||
JWT_EXPIRES_IN="7d"
|
JWT_EXPIRES_IN="7d"
|
||||||
PORT=3000
|
PORT=3010
|
||||||
MOCK_SMS=true
|
MOCK_SMS=true
|
||||||
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
|
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
|
||||||
ALIYUN_SMS_SIGN_NAME=
|
ALIYUN_SMS_SIGN_NAME=
|
||||||
|
|||||||
@@ -1248,7 +1248,10 @@ model Store {
|
|||||||
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
|
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
|
||||||
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
|
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
|
||||||
name String @db.VarChar(128)
|
name String @db.VarChar(128)
|
||||||
|
/// 门店主账号登录手机号(老板);与 StoreAccount.phone 对齐
|
||||||
phone String @db.VarChar(20)
|
phone String @db.VarChar(20)
|
||||||
|
/// 对外联系电话(店长等);C 端拨号展示用;空则回退 phone
|
||||||
|
contactPhone String? @map("contact_phone") @db.VarChar(20)
|
||||||
province String @db.VarChar(32)
|
province String @db.VarChar(32)
|
||||||
cityName String @map("city_name") @db.VarChar(32)
|
cityName String @map("city_name") @db.VarChar(32)
|
||||||
district String @db.VarChar(32)
|
district String @db.VarChar(32)
|
||||||
@@ -1277,6 +1280,8 @@ model Store {
|
|||||||
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
|
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
|
||||||
/// 测试门店:不计结算 / HQ 可手动标记
|
/// 测试门店:不计结算 / HQ 可手动标记
|
||||||
isTest Boolean @default(false) @map("is_test")
|
isTest Boolean @default(false) @map("is_test")
|
||||||
|
/// C 端 / 列表排序:数值越小越靠前
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
@@ -1299,6 +1304,7 @@ model Store {
|
|||||||
@@index([partnerAccountId])
|
@@index([partnerAccountId])
|
||||||
@@index([auditStatus, createdAt])
|
@@index([auditStatus, createdAt])
|
||||||
@@index([isTest])
|
@@index([isTest])
|
||||||
|
@@index([sortOrder])
|
||||||
@@map("store_store")
|
@@map("store_store")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1325,6 +1331,8 @@ model StorePackage {
|
|||||||
usableTime String? @map("usable_time") @db.VarChar(256)
|
usableTime String? @map("usable_time") @db.VarChar(256)
|
||||||
otherNotes String? @map("other_notes") @db.VarChar(512)
|
otherNotes String? @map("other_notes") @db.VarChar(512)
|
||||||
imageUrl String? @map("image_url") @db.VarChar(512)
|
imageUrl String? @map("image_url") @db.VarChar(512)
|
||||||
|
/// 套餐多图 URL 列表(JSON string[]),最多 20 张;imageUrl 同步为首图
|
||||||
|
imageUrls Json? @map("image_urls")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../common/prisma/prisma.module';
|
import { PrismaService } from '../common/prisma/prisma.module';
|
||||||
import { CourierService } from '../integrations/courier/courier.service';
|
import { CourierService } from '../integrations/courier/courier.service';
|
||||||
|
import { parseCourierCallbackBody } from '../integrations/courier/xiaofeixia/xiaofeixia-callback-body';
|
||||||
import { XiaofeixiaProvider } from '../integrations/courier/xiaofeixia/xiaofeixia.provider';
|
import { XiaofeixiaProvider } from '../integrations/courier/xiaofeixia/xiaofeixia.provider';
|
||||||
import { logCourierCall } from '../integrations/courier/courier-log.util';
|
import { logCourierCall } from '../integrations/courier/courier-log.util';
|
||||||
import { TradeService } from '../modules/trade/trade.service';
|
import { TradeService } from '../modules/trade/trade.service';
|
||||||
@@ -54,7 +55,9 @@ export class DeliveryCallbackService {
|
|||||||
return this.courier.buildTrackCallbackResponse(true);
|
return this.courier.buildTrackCallbackResponse(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = this.xiaofeixiaProvider.parseTrackCallback(body);
|
const payload =
|
||||||
|
this.xiaofeixiaProvider.parseTrackCallback(body) ??
|
||||||
|
this.parseFromRawMeta(meta);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
const response = this.courier.buildTrackCallbackResponse(false);
|
const response = this.courier.buildTrackCallbackResponse(false);
|
||||||
await logCourierCall(this.prisma, {
|
await logCourierCall(this.prisma, {
|
||||||
@@ -162,10 +165,17 @@ export class DeliveryCallbackService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseFromRawMeta(meta?: TrackCallbackRequestMeta) {
|
||||||
|
if (!meta?.rawBody?.trim()) return null;
|
||||||
|
const parsed = parseCourierCallbackBody(meta.rawBody, meta.contentType || '');
|
||||||
|
return this.xiaofeixiaProvider.parseTrackCallback(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
private redactSecrets(raw: string): string {
|
private redactSecrets(raw: string): string {
|
||||||
return raw
|
return raw
|
||||||
.replace(/(sign=)[^&\s]*/gi, '$1[REDACTED]')
|
.replace(/(sign=)[^&\s]*/gi, '$1[REDACTED]')
|
||||||
.replace(/("sign"\s*:\s*")[^"]*/gi, '$1[REDACTED]')
|
.replace(/("sign"\s*:\s*")[^"]*/gi, '$1[REDACTED]')
|
||||||
.replace(/(api[_-]?key=)[^&\s]*/gi, '$1[REDACTED]');
|
.replace(/(api[_-]?key=)[^&\s]*/gi, '$1[REDACTED]')
|
||||||
|
.replace(/(name="sign"[\s\S]*?\r?\n\r?\n)([^\r\n-]+)/gi, '$1[REDACTED]');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,36 @@ export function mapOrderCompat<T extends OrderLike & {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapStoreCompat<T extends { coverResource?: { url?: string } | null }>(store: T) {
|
/** 对外联系电话;未单独配置时回退登录手机号 */
|
||||||
return {
|
export function resolveStoreContactPhone(store: {
|
||||||
|
phone?: string | null;
|
||||||
|
contactPhone?: string | null;
|
||||||
|
}): string {
|
||||||
|
const contact = String(store.contactPhone ?? '').trim();
|
||||||
|
if (contact) return contact;
|
||||||
|
return String(store.phone ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapStoreCompat<
|
||||||
|
T extends {
|
||||||
|
coverResource?: { url?: string } | null;
|
||||||
|
phone?: string | null;
|
||||||
|
contactPhone?: string | null;
|
||||||
|
},
|
||||||
|
>(store: T, opts?: { /** C 端:phone 字段对外可拨打号码 */ publicDial?: boolean }) {
|
||||||
|
const contactPhone = resolveStoreContactPhone(store) || null;
|
||||||
|
const mapped = {
|
||||||
...store,
|
...store,
|
||||||
coverUrl: store.coverResource?.url ?? null,
|
coverUrl: store.coverResource?.url ?? null,
|
||||||
|
contactPhone,
|
||||||
};
|
};
|
||||||
|
if (opts?.publicDial) {
|
||||||
|
return {
|
||||||
|
...mapped,
|
||||||
|
phone: contactPhone || store.phone || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapStatusLogCompat(events: CommonEvent[]) {
|
export function mapStatusLogCompat(events: CommonEvent[]) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 门店签约合同(CommonResource bizType='CONTRACT')多附件工具。
|
||||||
|
*
|
||||||
|
* 历史上合同只存单条记录(字段 contractUrl:string),现改为支持多张照片 / PDF。
|
||||||
|
* 入参同时兼容新的 contractUrls:string[] 与旧的 contractUrl:string。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_CONTRACT_FILES = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化合同附件地址:去空白、去重、限制数量。
|
||||||
|
* @param urls 新字段 contractUrls
|
||||||
|
* @param legacy 旧字段 contractUrl(仅在 urls 未提供时生效)
|
||||||
|
*/
|
||||||
|
export function normalizeContractUrls(
|
||||||
|
urls?: unknown,
|
||||||
|
legacy?: string | null,
|
||||||
|
): string[] {
|
||||||
|
const raw: unknown[] = Array.isArray(urls)
|
||||||
|
? urls
|
||||||
|
: legacy != null
|
||||||
|
? [legacy]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
const url = String(item ?? '').trim();
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
if (out.length >= MAX_CONTRACT_FILES) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF 存 FILE,其余(合同照片)存 IMAGE,便于前端按图片预览 */
|
||||||
|
export function contractMediaType(url: string): 'FILE' | 'IMAGE' {
|
||||||
|
return /\.pdf(\?|$)/i.test(url) ? 'FILE' : 'IMAGE';
|
||||||
|
}
|
||||||
@@ -213,6 +213,23 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
placeholder: '13203801799',
|
placeholder: '13203801799',
|
||||||
description: 'C 端联系客服拨号号码',
|
description: 'C 端联系客服拨号号码',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'PARTNER_ONBOARD_CS_QR_URL',
|
||||||
|
label: '合伙人入驻 · 企微客服二维码',
|
||||||
|
group: G.wechat_mini,
|
||||||
|
type: 'image',
|
||||||
|
requiresRestart: false,
|
||||||
|
description: '合伙人 H5 录入门店提交前展示;未配置时禁止提交入驻',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'PARTNER_ONBOARD_CS_HINT',
|
||||||
|
label: '合伙人入驻 · 客服提示文案',
|
||||||
|
group: G.wechat_mini,
|
||||||
|
type: 'string',
|
||||||
|
requiresRestart: false,
|
||||||
|
placeholder: '使用问题、提现问题等随时可联系【杜康好客】客服',
|
||||||
|
description: '二维码下方说明;留空用默认文案',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'MOCK_SMS_FIXED_CODE',
|
key: 'MOCK_SMS_FIXED_CODE',
|
||||||
label: 'Mock 短信固定验证码',
|
label: 'Mock 短信固定验证码',
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { parse as parseQueryString } from 'node:querystring';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小飞侠路由回调实际推送多为 multipart/form-data(非 JSON / urlencoded)。
|
||||||
|
* 解析失败时保留 _rawText,便于第三方日志排查。
|
||||||
|
*/
|
||||||
|
export function parseCourierCallbackBody(
|
||||||
|
rawText: string,
|
||||||
|
contentType: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const ct = contentType.toLowerCase();
|
||||||
|
const trimmed = rawText.trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
|
||||||
|
if (ct.includes('multipart/form-data') || looksLikeMultipart(trimmed)) {
|
||||||
|
const multipart = parseMultipartFormData(rawText, contentType);
|
||||||
|
if (multipart && Object.keys(multipart).length > 0) return multipart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.includes('application/json') || trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
return parsed && typeof parsed === 'object'
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: { value: parsed };
|
||||||
|
} catch {
|
||||||
|
return { _rawText: rawText.slice(0, 4000) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.includes('application/x-www-form-urlencoded') || looksLikeUrlEncoded(trimmed)) {
|
||||||
|
return flattenQueryValues(parseQueryString(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { _rawText: rawText.slice(0, 4000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeMultipart(raw: string): boolean {
|
||||||
|
return raw.startsWith('--') && /Content-Disposition:\s*form-data/i.test(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUrlEncoded(raw: string): boolean {
|
||||||
|
if (looksLikeMultipart(raw)) return false;
|
||||||
|
return /^[^=&\s]+=/.test(raw) && !raw.includes('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenQueryValues(
|
||||||
|
parsed: Record<string, string | string[] | undefined>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (Array.isArray(value)) out[key] = value[value.length - 1];
|
||||||
|
else if (value !== undefined) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅解析文本字段(小飞侠回调无文件) */
|
||||||
|
export function parseMultipartFormData(
|
||||||
|
rawText: string,
|
||||||
|
contentType: string,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const boundary = extractBoundary(contentType, rawText);
|
||||||
|
if (!boundary) return null;
|
||||||
|
|
||||||
|
const delimiter = `--${boundary}`;
|
||||||
|
const parts = rawText.split(delimiter);
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmedPart = part.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
|
||||||
|
if (!trimmedPart || trimmedPart === '--' || trimmedPart.startsWith('--')) continue;
|
||||||
|
|
||||||
|
let headers = '';
|
||||||
|
let body = '';
|
||||||
|
const crlfIdx = trimmedPart.indexOf('\r\n\r\n');
|
||||||
|
const lfIdx = trimmedPart.indexOf('\n\n');
|
||||||
|
if (crlfIdx >= 0 && (lfIdx < 0 || crlfIdx <= lfIdx)) {
|
||||||
|
headers = trimmedPart.slice(0, crlfIdx);
|
||||||
|
body = trimmedPart.slice(crlfIdx + 4);
|
||||||
|
} else if (lfIdx >= 0) {
|
||||||
|
headers = trimmedPart.slice(0, lfIdx);
|
||||||
|
body = trimmedPart.slice(lfIdx + 2);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = body.replace(/\r?\n$/, '');
|
||||||
|
|
||||||
|
const nameMatch = /Content-Disposition:[^\r\n]*;\s*name="([^"]+)"/i.exec(headers);
|
||||||
|
if (!nameMatch?.[1]) continue;
|
||||||
|
if (/filename=/i.test(headers)) continue;
|
||||||
|
result[nameMatch[1]] = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBoundary(contentType: string, rawText: string): string | null {
|
||||||
|
const fromHeader = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
|
||||||
|
if (fromHeader?.[1] || fromHeader?.[2]) {
|
||||||
|
return (fromHeader[1] || fromHeader[2] || '').trim() || null;
|
||||||
|
}
|
||||||
|
const firstLine = rawText.split(/\r?\n/, 1)[0]?.trim() ?? '';
|
||||||
|
if (firstLine.startsWith('--') && firstLine.length > 2) {
|
||||||
|
return firstLine.slice(2).replace(/--$/, '') || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -139,20 +139,15 @@ export class XiaofeixiaProvider implements ICourierProvider {
|
|||||||
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
|
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
|
||||||
if (!body || typeof body !== 'object') return null;
|
if (!body || typeof body !== 'object') return null;
|
||||||
const root = body as Record<string, unknown>;
|
const root = body as Record<string, unknown>;
|
||||||
// 兼容顶层字段或 data/payload 包裹
|
// 兼容顶层字段或 data/payload 包裹;data 若为 JSON 字符串也解开
|
||||||
const nested =
|
const nestedObj = this.unwrapNestedObject(root.data) ?? this.unwrapNestedObject(root.payload);
|
||||||
root.data && typeof root.data === 'object'
|
const raw = nestedObj ? { ...nestedObj, ...root } : root;
|
||||||
? (root.data as Record<string, unknown>)
|
const outNumber = this.firstString(raw, ['outNumber', 'out_number', 'outNo']);
|
||||||
: root.payload && typeof root.payload === 'object'
|
const trackingNumber = this.firstString(raw, ['number', 'trackingNumber', 'trackingNo']);
|
||||||
? (root.payload as Record<string, unknown>)
|
const status = this.firstString(raw, ['status']);
|
||||||
: null;
|
const statusName = this.firstString(raw, ['statusName', 'status_name']);
|
||||||
const raw = nested ? { ...nested, ...root } : root;
|
const trackInfo = this.firstString(raw, ['trackInfo', 'track_info']);
|
||||||
const outNumber = String(raw.outNumber ?? '').trim();
|
const createTime = this.firstString(raw, ['createTime', 'create_time']);
|
||||||
const trackingNumber = String(raw.number ?? raw.trackingNumber ?? '').trim();
|
|
||||||
const status = String(raw.status ?? '').trim();
|
|
||||||
const statusName = String(raw.statusName ?? '').trim();
|
|
||||||
const trackInfo = String(raw.trackInfo ?? '').trim();
|
|
||||||
const createTime = String(raw.createTime ?? '').trim();
|
|
||||||
if (!outNumber && !trackingNumber) return null;
|
if (!outNumber && !trackingNumber) return null;
|
||||||
if (!status && !trackInfo) return null;
|
if (!status && !trackInfo) return null;
|
||||||
return {
|
return {
|
||||||
@@ -165,6 +160,38 @@ export class XiaofeixiaProvider implements ICourierProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private unwrapNestedObject(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstString(raw: Record<string, unknown>, keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = raw[key];
|
||||||
|
if (value == null) continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const last = value[value.length - 1];
|
||||||
|
if (last != null && String(last).trim()) return String(last).trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
|
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
|
||||||
const name = (statusName || '').trim();
|
const name = (statusName || '').trim();
|
||||||
if (status === '5' || /签收/.test(name)) {
|
if (status === '5' || /签收/.test(name)) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { loadAppConfig } from '@dukang/shared-types';
|
|||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { json, urlencoded, type NextFunction, type Request, type Response } from 'express';
|
import { json, urlencoded, type NextFunction, type Request, type Response } from 'express';
|
||||||
import { parse as parseQueryString } from 'node:querystring';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
@@ -12,6 +11,7 @@ import { LoggingInterceptor } from './common/logging/logging.interceptor';
|
|||||||
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
|
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
|
||||||
import { AlertService } from './common/alert/alert.service';
|
import { AlertService } from './common/alert/alert.service';
|
||||||
import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
|
import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
|
||||||
|
import { parseCourierCallbackBody } from './integrations/courier/xiaofeixia/xiaofeixia-callback-body';
|
||||||
|
|
||||||
function isCourierTrackCallbackUrl(url?: string): boolean {
|
function isCourierTrackCallbackUrl(url?: string): boolean {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
@@ -22,33 +22,7 @@ function isCourierTrackCallbackUrl(url?: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCourierCallbackBody(
|
/** 小飞侠回调:无论 Content-Type,先吃下 rawBody 再尽力解析(含 multipart/form-data) */
|
||||||
rawText: string,
|
|
||||||
contentType: string,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const ct = contentType.toLowerCase();
|
|
||||||
const trimmed = rawText.trim();
|
|
||||||
if (!trimmed) return {};
|
|
||||||
|
|
||||||
if (ct.includes('application/json') || trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed) as unknown;
|
|
||||||
return parsed && typeof parsed === 'object'
|
|
||||||
? (parsed as Record<string, unknown>)
|
|
||||||
: { value: parsed };
|
|
||||||
} catch {
|
|
||||||
return { _rawText: rawText.slice(0, 4000) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ct.includes('application/x-www-form-urlencoded') || /[=&]/.test(trimmed)) {
|
|
||||||
return parseQueryString(trimmed) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { _rawText: rawText.slice(0, 4000) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 小飞侠回调:无论 Content-Type,先吃下 rawBody 再尽力解析(便于第三方日志排查) */
|
|
||||||
function courierTrackRawBodyMiddleware(req: Request, _res: Response, next: NextFunction) {
|
function courierTrackRawBodyMiddleware(req: Request, _res: Response, next: NextFunction) {
|
||||||
if (req.method !== 'POST' || !isCourierTrackCallbackUrl(req.originalUrl || req.url)) {
|
if (req.method !== 'POST' || !isCourierTrackCallbackUrl(req.originalUrl || req.url)) {
|
||||||
return next();
|
return next();
|
||||||
@@ -88,7 +62,7 @@ async function bootstrap() {
|
|||||||
app.use(
|
app.use(
|
||||||
json({
|
json({
|
||||||
type: (req) =>
|
type: (req) =>
|
||||||
!isCourierTrackCallbackUrl(req.originalUrl || req.url) &&
|
!isCourierTrackCallbackUrl((req as Request).originalUrl || req.url) &&
|
||||||
Boolean(req.headers['content-type']?.includes('json')),
|
Boolean(req.headers['content-type']?.includes('json')),
|
||||||
verify: (req, _res, buf) => {
|
verify: (req, _res, buf) => {
|
||||||
if (
|
if (
|
||||||
@@ -105,19 +79,20 @@ async function bootstrap() {
|
|||||||
urlencoded({
|
urlencoded({
|
||||||
extended: true,
|
extended: true,
|
||||||
type: (req) =>
|
type: (req) =>
|
||||||
!isCourierTrackCallbackUrl(req.originalUrl || req.url) &&
|
!isCourierTrackCallbackUrl((req as Request).originalUrl || req.url) &&
|
||||||
Boolean(req.headers['content-type']?.includes('urlencoded')),
|
Boolean(req.headers['content-type']?.includes('urlencoded')),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||||
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
|
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
|
||||||
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
|
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
|
||||||
const port = process.env.PORT || 3000;
|
const port = Number(process.env.PORT || 3010);
|
||||||
|
const host = process.env.HOST || '0.0.0.0';
|
||||||
const cfg = loadAppConfig();
|
const cfg = loadAppConfig();
|
||||||
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
|
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
|
||||||
console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`);
|
console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`);
|
||||||
await app.listen(port);
|
await app.listen(port, host);
|
||||||
console.log(`dukang-api listening on http://localhost:${port}/api/v1`);
|
console.log(`dukang-api listening on http://${host}:${port}/api/v1`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export class ClientConfigController {
|
|||||||
brandLogoMarkUrl: brand.brandLogoMarkUrl,
|
brandLogoMarkUrl: brand.brandLogoMarkUrl,
|
||||||
qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
|
qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
|
||||||
customerServicePhone: brand.customerServicePhone,
|
customerServicePhone: brand.customerServicePhone,
|
||||||
|
partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null,
|
||||||
|
partnerOnboardCsHint:
|
||||||
|
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
|
||||||
|
'使用问题、提现问题等随时可联系【杜康好客】客服',
|
||||||
share,
|
share,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { AlertService } from '../../common/alert/alert.service';
|
|||||||
import type { AlertLevel } from '../../common/alert/alert.constants';
|
import type { AlertLevel } from '../../common/alert/alert.constants';
|
||||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import type { ReportClientErrorDto } from './dto/client-error.dto';
|
import type { ReportClientErrorDto } from './dto/client-error.dto';
|
||||||
import { SupportTicketService } from './support-ticket.service';
|
|
||||||
|
|
||||||
const WECOM_LEVELS = new Set(['fatal', 'error']);
|
const WECOM_LEVELS = new Set(['fatal', 'error']);
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ export class ClientErrorService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly alert: AlertService,
|
private readonly alert: AlertService,
|
||||||
private readonly supportTicket: SupportTicketService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) {
|
async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) {
|
||||||
@@ -109,25 +107,6 @@ export class ClientErrorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.category === 'validation_error') {
|
|
||||||
const apiPath =
|
|
||||||
dto.extra && typeof dto.extra.url === 'string' ? dto.extra.url.slice(0, 256) : undefined;
|
|
||||||
void this.supportTicket
|
|
||||||
.createFromClientValidation({
|
|
||||||
clientApp,
|
|
||||||
message,
|
|
||||||
pagePath,
|
|
||||||
apiPath,
|
|
||||||
actorLabel:
|
|
||||||
user?.actorId != null ? `${user.actorType}:${String(user.actorId)}` : undefined,
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
this.logger.warn(
|
|
||||||
`auto support ticket failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,17 +38,8 @@ function generateSupportTicketNo() {
|
|||||||
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_VALIDATION_TICKET_APPS = new Set([
|
|
||||||
'USER_H5',
|
|
||||||
'USER_MINI',
|
|
||||||
'SHOP_H5',
|
|
||||||
'PARTNER_H5',
|
|
||||||
]);
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupportTicketService {
|
export class SupportTicketService {
|
||||||
private systemCreatorCache: { id: bigint; name: string } | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly alert: AlertService,
|
private readonly alert: AlertService,
|
||||||
@@ -105,66 +96,6 @@ export class SupportTicketService {
|
|||||||
return serializeBigInt(mapSupportTicketRow(ticket));
|
return serializeBigInt(mapSupportTicketRow(ticket));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 客户端 400 验证错误自动建单(1 小时内同标题去重) */
|
|
||||||
async createFromClientValidation(input: {
|
|
||||||
clientApp: string;
|
|
||||||
message: string;
|
|
||||||
pagePath?: string;
|
|
||||||
apiPath?: string;
|
|
||||||
actorLabel?: string;
|
|
||||||
}) {
|
|
||||||
if (!AUTO_VALIDATION_TICKET_APPS.has(input.clientApp)) {
|
|
||||||
return { skipped: true as const, reason: 'unsupported_app' as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = `[客户端验证] ${input.clientApp}${input.pagePath ? ` · ${input.pagePath}` : ''} · ${input.message.slice(0, 60)}`;
|
|
||||||
const oneHourAgo = new Date(Date.now() - 3600_000);
|
|
||||||
const existing = await this.prisma.commonSupportTicket.findFirst({
|
|
||||||
where: { title, createdAt: { gte: oneHourAgo } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
return { skipped: true as const, ticketId: existing.id.toString() };
|
|
||||||
}
|
|
||||||
|
|
||||||
const creator = await this.resolveSystemCreator();
|
|
||||||
const content = [
|
|
||||||
`端:${input.clientApp}`,
|
|
||||||
input.pagePath ? `页面:${input.pagePath}` : null,
|
|
||||||
input.apiPath ? `接口:${input.apiPath}` : null,
|
|
||||||
input.actorLabel ? `用户:${input.actorLabel}` : null,
|
|
||||||
'',
|
|
||||||
input.message,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const ticket = await this.create(
|
|
||||||
{
|
|
||||||
ticketType: 'BUG',
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
remark: '客户端验证错误自动上报',
|
|
||||||
},
|
|
||||||
creator,
|
|
||||||
);
|
|
||||||
return { skipped: false as const, ticketId: String(ticket.id) };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveSystemCreator() {
|
|
||||||
if (this.systemCreatorCache) return this.systemCreatorCache;
|
|
||||||
const account = await this.prisma.hqAccount.findFirst({
|
|
||||||
where: { adminRole: 'SUPER_ADMIN', status: 'ACTIVE' },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!account) {
|
|
||||||
throw new BadRequestException('未找到系统管理员账号,无法自动创建工单');
|
|
||||||
}
|
|
||||||
this.systemCreatorCache = { id: account.id, name: '系统自动' };
|
|
||||||
return this.systemCreatorCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateSupportTicketDto) {
|
async update(id: bigint, dto: UpdateSupportTicketDto) {
|
||||||
const ticket = await this.getOrThrow(id);
|
const ticket = await this.getOrThrow(id);
|
||||||
if (ticket.status !== 'PENDING_REVIEW') {
|
if (ticket.status !== 'PENDING_REVIEW') {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
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 { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
|
import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
|
||||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { StoreCategoryService } from '../store/store-category.service';
|
import { StoreCategoryService } from '../store/store-category.service';
|
||||||
@@ -72,7 +73,7 @@ export class AdminStoresService {
|
|||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.store.findMany({
|
this.prisma.store.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
@@ -92,6 +93,17 @@ export class AdminStoresService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.store.count({ where }),
|
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({
|
return serializeBigInt({
|
||||||
items: items.map((s) => {
|
items: items.map((s) => {
|
||||||
const { visibilityPhones, ...rest } = s;
|
const { visibilityPhones, ...rest } = s;
|
||||||
@@ -99,6 +111,8 @@ export class AdminStoresService {
|
|||||||
...rest,
|
...rest,
|
||||||
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
|
||||||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||||||
|
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
|
||||||
|
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
|
||||||
partner: s.partnerAccount,
|
partner: s.partnerAccount,
|
||||||
account: s.bindings[0]?.storeAccount ?? null,
|
account: s.bindings[0]?.storeAccount ?? null,
|
||||||
bindings: undefined,
|
bindings: undefined,
|
||||||
@@ -146,8 +160,10 @@ export class AdminStoresService {
|
|||||||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||||||
partner: store.partnerAccount,
|
partner: store.partnerAccount,
|
||||||
account: store.bindings[0]?.storeAccount ?? null,
|
account: store.bindings[0]?.storeAccount ?? null,
|
||||||
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
|
/** 门店端登录手机号(store_account.phone);与 store.phone 对齐 */
|
||||||
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
|
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
|
||||||
|
/** 对外联系电话;空则回退登录号 */
|
||||||
|
contactPhone: store.contactPhone?.trim() || store.phone,
|
||||||
bindings: undefined,
|
bindings: undefined,
|
||||||
media,
|
media,
|
||||||
audits,
|
audits,
|
||||||
@@ -274,12 +290,20 @@ export class AdminStoresService {
|
|||||||
|
|
||||||
if (dto.phone !== undefined) {
|
if (dto.phone !== undefined) {
|
||||||
const normalizedPhone = dto.phone.trim();
|
const normalizedPhone = dto.phone.trim();
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
if (!isMobilePhone(normalizedPhone)) {
|
||||||
throw new BadRequestException('请输入正确的手机号码');
|
throw new BadRequestException('请输入正确的登录手机号');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.contactPhone !== undefined) {
|
||||||
|
const contact = dto.contactPhone.trim();
|
||||||
|
if (contact && !isStoreContactPhone(contact)) {
|
||||||
|
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
|
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
|
||||||
|
const normalizedContactPhone =
|
||||||
|
dto.contactPhone !== undefined ? dto.contactPhone.trim() || null : undefined;
|
||||||
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
|
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
|
||||||
const nextEnabled =
|
const nextEnabled =
|
||||||
dto.visibilityWhitelistEnabled !== undefined
|
dto.visibilityWhitelistEnabled !== undefined
|
||||||
@@ -296,7 +320,7 @@ export class AdminStoresService {
|
|||||||
const needAccountSync =
|
const needAccountSync =
|
||||||
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
|
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
|
||||||
|
|
||||||
// 登录凭证在 store_account.phone;必须与门店展示手机号同步
|
// 登录凭证在 store_account.phone;与 store.phone(老板登录号)同步,不与对外联系电话绑定
|
||||||
let primaryBinding: {
|
let primaryBinding: {
|
||||||
storeAccount: { id: bigint; phone: string; name: string } | null;
|
storeAccount: { id: bigint; phone: string; name: string } | null;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
@@ -346,6 +370,9 @@ export class AdminStoresService {
|
|||||||
data: {
|
data: {
|
||||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
|
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
|
||||||
|
...(normalizedContactPhone !== undefined
|
||||||
|
? { contactPhone: normalizedContactPhone }
|
||||||
|
: {}),
|
||||||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||||
...(dto.benefitUsageRule !== undefined
|
...(dto.benefitUsageRule !== undefined
|
||||||
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
|
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
|
||||||
@@ -365,6 +392,7 @@ export class AdminStoresService {
|
|||||||
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
||||||
: {}),
|
: {}),
|
||||||
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
|
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: Math.floor(Number(dto.sortOrder)) || 0 } : {}),
|
||||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -373,11 +401,24 @@ export class AdminStoresService {
|
|||||||
// 分实体手机号已废弃,忽略写入
|
// 分实体手机号已废弃,忽略写入
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl !== undefined) {
|
||||||
if (current.coverResourceId) {
|
const coverUrl = dto.coverUrl?.trim() || '';
|
||||||
|
if (!coverUrl) {
|
||||||
|
if (current.coverResourceId) {
|
||||||
|
await tx.commonResource.update({
|
||||||
|
where: { id: current.coverResourceId },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
await tx.store.update({ where: { id }, data: { coverResourceId: null } });
|
||||||
|
}
|
||||||
|
await tx.commonResource.updateMany({
|
||||||
|
where: { ownerType: 'STORE', ownerId: id, bizType: 'COVER', status: 'ACTIVE' },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
} else if (current.coverResourceId) {
|
||||||
await tx.commonResource.update({
|
await tx.commonResource.update({
|
||||||
where: { id: current.coverResourceId },
|
where: { id: current.coverResourceId },
|
||||||
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
|
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const cover = await tx.commonResource.create({
|
const cover = await tx.commonResource.create({
|
||||||
@@ -387,14 +428,60 @@ export class AdminStoresService {
|
|||||||
bizType: 'COVER',
|
bizType: 'COVER',
|
||||||
mediaType: 'IMAGE',
|
mediaType: 'IMAGE',
|
||||||
ossBucket: 'legacy',
|
ossBucket: 'legacy',
|
||||||
ossKey: dto.coverUrl,
|
ossKey: coverUrl,
|
||||||
url: dto.coverUrl,
|
url: coverUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
|
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.envPhotoUrls !== undefined) {
|
||||||
|
const envUrls = [...new Set(
|
||||||
|
(dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean),
|
||||||
|
)].slice(0, 20);
|
||||||
|
await tx.commonResource.updateMany({
|
||||||
|
where: { ownerType: 'STORE', ownerId: id, bizType: 'ENV', status: 'ACTIVE' },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
for (let i = 0; i < envUrls.length; i++) {
|
||||||
|
await tx.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: id,
|
||||||
|
bizType: 'ENV',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossBucket: 'legacy',
|
||||||
|
ossKey: envUrls[i],
|
||||||
|
url: envUrls[i],
|
||||||
|
sortOrder: i,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.contractUrls !== undefined || dto.contractUrl !== undefined) {
|
||||||
|
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
|
||||||
|
await tx.commonResource.updateMany({
|
||||||
|
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
for (let i = 0; i < contractUrls.length; i++) {
|
||||||
|
await tx.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: id,
|
||||||
|
bizType: 'CONTRACT',
|
||||||
|
mediaType: contractMediaType(contractUrls[i]),
|
||||||
|
ossBucket: 'legacy',
|
||||||
|
ossKey: contractUrls[i],
|
||||||
|
url: contractUrls[i],
|
||||||
|
sortOrder: i,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (primaryBinding?.storeAccount) {
|
if (primaryBinding?.storeAccount) {
|
||||||
const account = primaryBinding.storeAccount;
|
const account = primaryBinding.storeAccount;
|
||||||
const accountData: {
|
const accountData: {
|
||||||
@@ -431,7 +518,7 @@ export class AdminStoresService {
|
|||||||
|
|
||||||
async createStore(dto: CreateStoreDto) {
|
async createStore(dto: CreateStoreDto) {
|
||||||
const normalizedPhone = dto.phone.trim();
|
const normalizedPhone = dto.phone.trim();
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
if (!isMobilePhone(normalizedPhone)) {
|
||||||
throw new BadRequestException('请输入正确的手机号码');
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
}
|
}
|
||||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||||
@@ -501,6 +588,11 @@ export class AdminStoresService {
|
|||||||
? !!dto.isTest
|
? !!dto.isTest
|
||||||
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||||||
|
|
||||||
|
const contactPhoneRaw = dto.contactPhone?.trim() || normalizedPhone;
|
||||||
|
if (!isStoreContactPhone(contactPhoneRaw)) {
|
||||||
|
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||||
|
}
|
||||||
|
|
||||||
const store = await this.prisma.store.create({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
@@ -509,6 +601,7 @@ export class AdminStoresService {
|
|||||||
categoryId,
|
categoryId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
|
contactPhone: contactPhoneRaw,
|
||||||
province: dto.province ?? city.province,
|
province: dto.province ?? city.province,
|
||||||
cityName: dto.city ?? city.name,
|
cityName: dto.city ?? city.name,
|
||||||
district: dto.district ?? '',
|
district: dto.district ?? '',
|
||||||
@@ -522,6 +615,7 @@ export class AdminStoresService {
|
|||||||
closeTime2: closeTime2 || null,
|
closeTime2: closeTime2 || null,
|
||||||
visibilityWhitelistEnabled: whitelistEnabled,
|
visibilityWhitelistEnabled: whitelistEnabled,
|
||||||
isTest,
|
isTest,
|
||||||
|
sortOrder: dto.sortOrder != null ? Math.floor(Number(dto.sortOrder)) || 0 : 0,
|
||||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
auditStatus: 'APPROVED',
|
auditStatus: 'APPROVED',
|
||||||
@@ -545,7 +639,7 @@ export class AdminStoresService {
|
|||||||
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean);
|
const envUrls = [...new Set((dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean))].slice(0, 20);
|
||||||
for (let i = 0; i < envUrls.length; i++) {
|
for (let i = 0; i < envUrls.length; i++) {
|
||||||
await this.prisma.commonResource.create({
|
await this.prisma.commonResource.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -561,16 +655,18 @@ export class AdminStoresService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.contractUrl) {
|
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
|
||||||
|
for (let i = 0; i < contractUrls.length; i++) {
|
||||||
await this.prisma.commonResource.create({
|
await this.prisma.commonResource.create({
|
||||||
data: {
|
data: {
|
||||||
ownerType: 'STORE',
|
ownerType: 'STORE',
|
||||||
ownerId: store.id,
|
ownerId: store.id,
|
||||||
bizType: 'CONTRACT',
|
bizType: 'CONTRACT',
|
||||||
mediaType: 'FILE',
|
mediaType: contractMediaType(contractUrls[i]),
|
||||||
ossBucket: 'legacy',
|
ossBucket: 'legacy',
|
||||||
ossKey: dto.contractUrl,
|
ossKey: contractUrls[i],
|
||||||
url: dto.contractUrl,
|
url: contractUrls[i],
|
||||||
|
sortOrder: i,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,28 @@ import type { AdminUsersQueryDto } from './dto/admin-query.dto';
|
|||||||
|
|
||||||
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
|
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
|
||||||
|
|
||||||
|
/** 权益统计口径:VOID(退款作废)不计入,与 C 端 listCoupons 一致 */
|
||||||
|
const BENEFIT_STAT_STATUSES = ['ACTIVE', 'USED_UP'] as const;
|
||||||
|
|
||||||
|
export type UserBenefitStat = {
|
||||||
|
/** 累计获得(含已使用) */
|
||||||
|
benefitTotalAmount: number;
|
||||||
|
/** 已使用(已核销) */
|
||||||
|
benefitUsedAmount: number;
|
||||||
|
/** 剩余未使用(可核销余额) */
|
||||||
|
benefitBalance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_BENEFIT_STAT: UserBenefitStat = {
|
||||||
|
benefitTotalAmount: 0,
|
||||||
|
benefitUsedAmount: 0,
|
||||||
|
benefitBalance: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function toAmount(v: Prisma.Decimal | number | null | undefined) {
|
||||||
|
return v == null ? 0 : Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
function mapAdminUserRow(u: {
|
function mapAdminUserRow(u: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userNo: string;
|
userNo: string;
|
||||||
@@ -23,7 +45,7 @@ function mapAdminUserRow(u: {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
_count: { orders: number };
|
_count: { orders: number };
|
||||||
}) {
|
}, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) {
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
userNo: u.userNo,
|
userNo: u.userNo,
|
||||||
@@ -42,6 +64,7 @@ function mapAdminUserRow(u: {
|
|||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
updatedAt: u.updatedAt,
|
updatedAt: u.updatedAt,
|
||||||
orderCount: u._count.orders,
|
orderCount: u._count.orders,
|
||||||
|
...benefit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +113,37 @@ export class AdminUsersService {
|
|||||||
this.prisma.user.count({ where }),
|
this.prisma.user.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const benefitMap = await this.loadBenefitStats(items.map((u) => u.id));
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
items: items.map((u) => mapAdminUserRow(u)),
|
items: items.map((u) => mapAdminUserRow(u, benefitMap.get(u.id.toString()))),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量聚合当前页用户的好客权益金额(累计获得 / 已使用 / 剩余) */
|
||||||
|
private async loadBenefitStats(userIds: bigint[]): Promise<Map<string, UserBenefitStat>> {
|
||||||
|
const map = new Map<string, UserBenefitStat>();
|
||||||
|
if (!userIds.length) return map;
|
||||||
|
|
||||||
|
const grouped = await this.prisma.benefitCoupon.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: { userId: { in: userIds }, status: { in: [...BENEFIT_STAT_STATUSES] } },
|
||||||
|
_sum: { totalAmount: true, usedAmount: true, balance: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of grouped) {
|
||||||
|
map.set(row.userId.toString(), {
|
||||||
|
benefitTotalAmount: toAmount(row._sum.totalAmount),
|
||||||
|
benefitUsedAmount: toAmount(row._sum.usedAmount),
|
||||||
|
benefitBalance: toAmount(row._sum.balance),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
async detail(id: bigint) {
|
async detail(id: bigint) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -128,8 +174,11 @@ export class AdminUsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const benefitMap = await this.loadBenefitStats([user.id]);
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...user,
|
...user,
|
||||||
|
...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT),
|
||||||
wechatVerified: !!user.wxOpenId,
|
wechatVerified: !!user.wxOpenId,
|
||||||
mergedFromCount: user._count.mergedFrom,
|
mergedFromCount: user._count.mergedFrom,
|
||||||
orderCount: user._count.orders,
|
orderCount: user._count.orders,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
@@ -37,6 +38,11 @@ export class CreateStoreDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
|
/** 对外联系电话(店长);不传则默认与登录手机号相同 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
contactPhone?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
@@ -96,12 +102,21 @@ export class CreateStoreDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
@ArrayMaxSize(20)
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
|
|
||||||
|
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
contractUrl?: string;
|
contractUrl?: string;
|
||||||
|
|
||||||
|
/** 签约合同(支持多张照片 / PDF) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
contractUrls?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@@ -143,6 +158,12 @@ export class CreateStoreDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
|
|
||||||
|
/** 列表排序,越小越靠前 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateStoreDto {
|
export class UpdateStoreDto {
|
||||||
@@ -154,6 +175,11 @@ export class UpdateStoreDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
||||||
|
/** 对外联系电话(店长) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
contactPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
intro?: string;
|
intro?: string;
|
||||||
@@ -164,7 +190,25 @@ export class UpdateStoreDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
coverUrl?: string;
|
coverUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
envPhotoUrls?: string[];
|
||||||
|
|
||||||
|
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
contractUrl?: string | null;
|
||||||
|
|
||||||
|
/** 签约合同(支持多张照片 / PDF),传空数组即清空 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
contractUrls?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -247,6 +291,12 @@ export class UpdateStoreDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
|
|
||||||
|
/** 列表排序,越小越靠前 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateStoreAccountDto {
|
export class CreateStoreAccountDto {
|
||||||
|
|||||||
@@ -1708,6 +1708,9 @@ export class SettlementService {
|
|||||||
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
|
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
|
||||||
if (!bill) throw new NotFoundException('酒厂对账单不存在');
|
if (!bill) throw new NotFoundException('酒厂对账单不存在');
|
||||||
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
|
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
|
||||||
|
if (Number(bill.wineryAmount) === 0) {
|
||||||
|
throw new BadRequestException('应付为 0,无需打款');
|
||||||
|
}
|
||||||
const updated = await this.prisma.wineryBill.update({
|
const updated = await this.prisma.wineryBill.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'PAID', paidAt: new Date() },
|
data: { status: 'PAID', paidAt: new Date() },
|
||||||
@@ -1749,11 +1752,17 @@ export class SettlementService {
|
|||||||
'配送类型',
|
'配送类型',
|
||||||
'实付金额',
|
'实付金额',
|
||||||
'酒厂比例',
|
'酒厂比例',
|
||||||
'酒厂应付',
|
'应付',
|
||||||
'支付时间',
|
'支付时间',
|
||||||
'账单状态',
|
'账单状态',
|
||||||
].join(',');
|
].join(',');
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
|
const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => {
|
||||||
|
if (Number(b.wineryAmount) === 0) return '无需打款';
|
||||||
|
if (b.status === 'PAID') return '已打款';
|
||||||
|
if (b.status === 'UNPAID') return '未打款';
|
||||||
|
return b.status;
|
||||||
|
};
|
||||||
for (const b of bills) {
|
for (const b of bills) {
|
||||||
if (b.items.length === 0) {
|
if (b.items.length === 0) {
|
||||||
rows.push(
|
rows.push(
|
||||||
@@ -1766,7 +1775,7 @@ export class SettlementService {
|
|||||||
Number(b.wineryRate),
|
Number(b.wineryRate),
|
||||||
Number(b.wineryAmount),
|
Number(b.wineryAmount),
|
||||||
'',
|
'',
|
||||||
b.status,
|
statusLabel(b),
|
||||||
].join(','),
|
].join(','),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -1782,7 +1791,7 @@ export class SettlementService {
|
|||||||
Number(b.wineryRate),
|
Number(b.wineryRate),
|
||||||
Number(item.wineryAmount),
|
Number(item.wineryAmount),
|
||||||
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
|
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
|
||||||
b.status,
|
statusLabel(b),
|
||||||
].join(','),
|
].join(','),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1798,7 +1807,15 @@ export class SettlementService {
|
|||||||
month?: number;
|
month?: number;
|
||||||
}): Prisma.WineryBillWhereInput {
|
}): Prisma.WineryBillWhereInput {
|
||||||
const where: Prisma.WineryBillWhereInput = {};
|
const where: Prisma.WineryBillWhereInput = {};
|
||||||
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
|
if (query.status === 'NO_PAYMENT_NEEDED') {
|
||||||
|
where.wineryAmount = 0;
|
||||||
|
} else if (query.status === 'UNPAID') {
|
||||||
|
where.status = 'UNPAID';
|
||||||
|
where.wineryAmount = { gt: 0 };
|
||||||
|
} else if (query.status === 'PAID') {
|
||||||
|
where.status = 'PAID';
|
||||||
|
where.wineryAmount = { gt: 0 };
|
||||||
|
}
|
||||||
if (query.year && query.month) {
|
if (query.year && query.month) {
|
||||||
const start = new Date(query.year, query.month - 1, 1);
|
const start = new Date(query.year, query.month - 1, 1);
|
||||||
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export class AdminStorePackageController {
|
|||||||
export class AdminStorePackageAuditController {
|
export class AdminStorePackageAuditController {
|
||||||
constructor(private readonly packages: StorePackageService) {}
|
constructor(private readonly packages: StorePackageService) {}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
summary() {
|
||||||
|
return this.packages.adminAuditSummary();
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
list(
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { STORE_PACKAGE_MAX_COUNT, type StorePackageItemDto } from '@dukang/shared-types';
|
import {
|
||||||
|
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
||||||
|
STORE_PACKAGE_MAX_COUNT,
|
||||||
|
normalizeStorePackageImageUrls,
|
||||||
|
type StorePackageItemDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
@@ -44,9 +49,18 @@ export class StorePackageService {
|
|||||||
const otherNotes = item.otherNotes != null && String(item.otherNotes).trim()
|
const otherNotes = item.otherNotes != null && String(item.otherNotes).trim()
|
||||||
? String(item.otherNotes).trim()
|
? String(item.otherNotes).trim()
|
||||||
: null;
|
: null;
|
||||||
const imageUrl = item.imageUrl != null && String(item.imageUrl).trim()
|
const imageUrls = normalizeStorePackageImageUrls({
|
||||||
? String(item.imageUrl).trim()
|
imageUrl: item.imageUrl as string | null | undefined,
|
||||||
: null;
|
imageUrls: item.imageUrls,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
Array.isArray(item.imageUrls) &&
|
||||||
|
item.imageUrls.filter((u) => String(u ?? '').trim()).length > STORE_PACKAGE_IMAGE_MAX_COUNT
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`第 ${index + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const sortOrder = item.sortOrder != null ? Number(item.sortOrder) : index;
|
const sortOrder = item.sortOrder != null ? Number(item.sortOrder) : index;
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -54,7 +68,8 @@ export class StorePackageService {
|
|||||||
dishes,
|
dishes,
|
||||||
usableTime,
|
usableTime,
|
||||||
otherNotes,
|
otherNotes,
|
||||||
imageUrl,
|
imageUrl: imageUrls[0] ?? null,
|
||||||
|
imageUrls,
|
||||||
sortOrder: Number.isFinite(sortOrder) ? sortOrder : index,
|
sortOrder: Number.isFinite(sortOrder) ? sortOrder : index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -67,8 +82,13 @@ export class StorePackageService {
|
|||||||
usableTime: string | null;
|
usableTime: string | null;
|
||||||
otherNotes: string | null;
|
otherNotes: string | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
|
imageUrls: Prisma.JsonValue | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}) {
|
}) {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls({
|
||||||
|
imageUrl: row.imageUrl,
|
||||||
|
imageUrls: row.imageUrls,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id: row.id.toString(),
|
id: row.id.toString(),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -76,7 +96,8 @@ export class StorePackageService {
|
|||||||
dishes: row.dishes,
|
dishes: row.dishes,
|
||||||
usableTime: row.usableTime,
|
usableTime: row.usableTime,
|
||||||
otherNotes: row.otherNotes,
|
otherNotes: row.otherNotes,
|
||||||
imageUrl: row.imageUrl,
|
imageUrl: imageUrls[0] ?? null,
|
||||||
|
imageUrls,
|
||||||
sortOrder: row.sortOrder,
|
sortOrder: row.sortOrder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -208,6 +229,19 @@ export class StorePackageService {
|
|||||||
return serializeBigInt({ live });
|
return serializeBigInt({ live });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private packageImageWrite(pkg: {
|
||||||
|
imageUrl?: string | null;
|
||||||
|
imageUrls?: string[] | null;
|
||||||
|
}) {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(pkg);
|
||||||
|
return {
|
||||||
|
imageUrl: imageUrls[0] ?? null,
|
||||||
|
imageUrls: imageUrls.length
|
||||||
|
? (imageUrls as Prisma.InputJsonValue)
|
||||||
|
: Prisma.JsonNull,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async replaceLivePackages(storeId: bigint, packages: StorePackageItemDto[]) {
|
private async replaceLivePackages(storeId: bigint, packages: StorePackageItemDto[]) {
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.storePackage.deleteMany({ where: { storeId } }),
|
this.prisma.storePackage.deleteMany({ where: { storeId } }),
|
||||||
@@ -220,7 +254,7 @@ export class StorePackageService {
|
|||||||
dishes: pkg.dishes,
|
dishes: pkg.dishes,
|
||||||
usableTime: pkg.usableTime ?? null,
|
usableTime: pkg.usableTime ?? null,
|
||||||
otherNotes: pkg.otherNotes ?? null,
|
otherNotes: pkg.otherNotes ?? null,
|
||||||
imageUrl: pkg.imageUrl ?? null,
|
...this.packageImageWrite(pkg),
|
||||||
sortOrder: pkg.sortOrder ?? index,
|
sortOrder: pkg.sortOrder ?? index,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -312,7 +346,7 @@ export class StorePackageService {
|
|||||||
});
|
});
|
||||||
return serializeBigInt({ id: updated.id.toString(), status: updated.status });
|
return serializeBigInt({ id: updated.id.toString(), status: updated.status });
|
||||||
}
|
}
|
||||||
const packages = req.packagesJson as unknown as StorePackageItemDto[];
|
const packages = this.normalizePackages(req.packagesJson);
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.storePackage.deleteMany({ where: { storeId: req.storeId } });
|
await tx.storePackage.deleteMany({ where: { storeId: req.storeId } });
|
||||||
for (let i = 0; i < packages.length; i++) {
|
for (let i = 0; i < packages.length; i++) {
|
||||||
@@ -325,6 +359,7 @@ export class StorePackageService {
|
|||||||
dishes: pkg.dishes,
|
dishes: pkg.dishes,
|
||||||
usableTime: pkg.usableTime ?? null,
|
usableTime: pkg.usableTime ?? null,
|
||||||
otherNotes: pkg.otherNotes ?? null,
|
otherNotes: pkg.otherNotes ?? null,
|
||||||
|
...this.packageImageWrite(pkg),
|
||||||
sortOrder: pkg.sortOrder ?? i,
|
sortOrder: pkg.sortOrder ?? i,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -341,4 +376,11 @@ export class StorePackageService {
|
|||||||
});
|
});
|
||||||
return serializeBigInt({ id: requestId.toString(), status: 'APPROVED' });
|
return serializeBigInt({ id: requestId.toString(), status: 'APPROVED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async adminAuditSummary() {
|
||||||
|
const pendingCount = await this.prisma.storePackageChangeRequest.count({
|
||||||
|
where: { status: 'PENDING' },
|
||||||
|
});
|
||||||
|
return { pendingCount };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
||||||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
||||||
import { validateBusinessHours } from '@dukang/domain';
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
|
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
import { parseBigIntParam } from '../../common/parse-bigint';
|
import { parseBigIntParam } from '../../common/parse-bigint';
|
||||||
|
import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { AuthService } from '../iam/auth.service';
|
import { AuthService } from '../iam/auth.service';
|
||||||
@@ -153,7 +155,7 @@ export class StoreService {
|
|||||||
category: true,
|
category: true,
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled)
|
const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled)
|
||||||
@@ -171,17 +173,21 @@ export class StoreService {
|
|||||||
distanceMeters: number | null;
|
distanceMeters: number | null;
|
||||||
latitude?: unknown;
|
latitude?: unknown;
|
||||||
longitude?: unknown;
|
longitude?: unknown;
|
||||||
|
sortOrder?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const items: StoreListItem[] = [];
|
const items: StoreListItem[] = [];
|
||||||
for (const store of visible) {
|
for (const store of visible) {
|
||||||
const coords = await this.ensureStoreCoordinates(store);
|
const coords = await this.ensureStoreCoordinates(store);
|
||||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||||
const mapped = mapStoreCompat({
|
const mapped = mapStoreCompat(
|
||||||
...rest,
|
{
|
||||||
latitude: coords?.latitude ?? store.latitude,
|
...rest,
|
||||||
longitude: coords?.longitude ?? store.longitude,
|
latitude: coords?.latitude ?? store.latitude,
|
||||||
});
|
longitude: coords?.longitude ?? store.longitude,
|
||||||
|
},
|
||||||
|
{ publicDial: true },
|
||||||
|
);
|
||||||
const distanceMeters =
|
const distanceMeters =
|
||||||
hasUser && coords
|
hasUser && coords
|
||||||
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
|
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
|
||||||
@@ -189,13 +195,16 @@ export class StoreService {
|
|||||||
items.push({ ...mapped, distanceMeters });
|
items.push({ ...mapped, distanceMeters });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUser) {
|
items.sort((a, b) => {
|
||||||
items.sort((a, b) => {
|
const so = (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
|
||||||
|
if (so !== 0) return so;
|
||||||
|
if (hasUser) {
|
||||||
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
|
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||||
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
|
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||||
return da - db;
|
return da - db;
|
||||||
});
|
}
|
||||||
}
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
return serializeBigInt(items);
|
return serializeBigInt(items);
|
||||||
}
|
}
|
||||||
@@ -225,21 +234,32 @@ export class StoreService {
|
|||||||
});
|
});
|
||||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
mapStoreCompat({
|
mapStoreCompat(
|
||||||
...rest,
|
{
|
||||||
latitude: coords?.latitude ?? store.latitude,
|
...rest,
|
||||||
longitude: coords?.longitude ?? store.longitude,
|
latitude: coords?.latitude ?? store.latitude,
|
||||||
media,
|
longitude: coords?.longitude ?? store.longitude,
|
||||||
packages: packageRows.map((p) => ({
|
media,
|
||||||
name: p.name,
|
packages: packageRows.map((p) => {
|
||||||
price: p.price.toFixed(2),
|
const imageUrls = normalizeStorePackageImageUrls({
|
||||||
dishes: p.dishes,
|
imageUrl: p.imageUrl,
|
||||||
usableTime: p.usableTime,
|
imageUrls: p.imageUrls,
|
||||||
otherNotes: p.otherNotes,
|
});
|
||||||
imageUrl: p.imageUrl,
|
return {
|
||||||
sortOrder: p.sortOrder,
|
name: p.name,
|
||||||
})),
|
price: p.price.toFixed(2),
|
||||||
}),
|
dishes: p.dishes,
|
||||||
|
usableTime: p.usableTime,
|
||||||
|
otherNotes: p.otherNotes,
|
||||||
|
imageUrl: imageUrls[0] ?? null,
|
||||||
|
/** C 端始终返回数组,便于多图轮播 */
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: p.sortOrder,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ publicDial: true },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +280,9 @@ export class StoreService {
|
|||||||
const stores = await this.prisma.store.findMany({
|
const stores = await this.prisma.store.findMany({
|
||||||
where,
|
where,
|
||||||
include: { category: true, coverResource: true },
|
include: { category: true, coverResource: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
});
|
});
|
||||||
return serializeBigInt(stores.map(mapStoreCompat));
|
return serializeBigInt(stores.map((s) => mapStoreCompat(s)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
|
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
|
||||||
@@ -294,10 +314,10 @@ export class StoreService {
|
|||||||
async partnerCheckStorePhone(phone: string) {
|
async partnerCheckStorePhone(phone: string) {
|
||||||
const normalizedPhone = phone.trim();
|
const normalizedPhone = phone.trim();
|
||||||
if (!normalizedPhone) {
|
if (!normalizedPhone) {
|
||||||
return { available: false, message: '请填写联系电话' };
|
return { available: false, message: '请填写门店登录手机号' };
|
||||||
}
|
}
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||||
return { available: false, message: '联系电话须为11位手机号' };
|
return { available: false, message: '门店登录手机号须为11位手机号' };
|
||||||
}
|
}
|
||||||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||||
where: { phone: normalizedPhone },
|
where: { phone: normalizedPhone },
|
||||||
@@ -347,14 +367,25 @@ export class StoreService {
|
|||||||
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
|
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
|
||||||
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
|
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
|
||||||
|
|
||||||
|
const contactPhoneRaw =
|
||||||
|
body.contactPhone != null && String(body.contactPhone).trim()
|
||||||
|
? String(body.contactPhone).trim()
|
||||||
|
: normalizedPhone;
|
||||||
|
if (!isStoreContactPhone(contactPhoneRaw)) {
|
||||||
|
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||||
|
}
|
||||||
|
|
||||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||||
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
||||||
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
const contractUrls = normalizeContractUrls(
|
||||||
|
body.contractUrls,
|
||||||
|
body.contractUrl != null ? String(body.contractUrl) : null,
|
||||||
|
);
|
||||||
|
|
||||||
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||||
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
||||||
if (!contractUrl) throw new BadRequestException('请上传签约合同');
|
if (!contractUrls.length) throw new BadRequestException('请上传签约合同');
|
||||||
|
|
||||||
const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
|
const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
|
||||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
||||||
@@ -403,6 +434,7 @@ export class StoreService {
|
|||||||
categoryId,
|
categoryId,
|
||||||
name: String(body.name),
|
name: String(body.name),
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
|
contactPhone: contactPhoneRaw,
|
||||||
province: String(body.province ?? city.province ?? '河南省'),
|
province: String(body.province ?? city.province ?? '河南省'),
|
||||||
cityName: String(body.city ?? city.name ?? '郑州市'),
|
cityName: String(body.city ?? city.name ?? '郑州市'),
|
||||||
district: String(body.district ?? ''),
|
district: String(body.district ?? ''),
|
||||||
@@ -458,16 +490,17 @@ export class StoreService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contractUrl) {
|
for (let i = 0; i < contractUrls.length; i++) {
|
||||||
await this.prisma.commonResource.create({
|
await this.prisma.commonResource.create({
|
||||||
data: {
|
data: {
|
||||||
ownerType: 'STORE',
|
ownerType: 'STORE',
|
||||||
ownerId: store.id,
|
ownerId: store.id,
|
||||||
bizType: 'CONTRACT',
|
bizType: 'CONTRACT',
|
||||||
mediaType: 'FILE',
|
mediaType: contractMediaType(contractUrls[i]),
|
||||||
ossBucket,
|
ossBucket,
|
||||||
ossKey: contractUrl,
|
ossKey: contractUrls[i],
|
||||||
url: contractUrl,
|
url: contractUrls[i],
|
||||||
|
sortOrder: i,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -601,7 +634,13 @@ export class StoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = body.name !== undefined ? String(body.name).trim() : undefined;
|
const name = body.name !== undefined ? String(body.name).trim() : undefined;
|
||||||
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
|
// 合伙人资料修改:联系电话 → contactPhone(兼容旧客户端把 phone 当联系电话提交)
|
||||||
|
const contactPhone =
|
||||||
|
body.contactPhone !== undefined
|
||||||
|
? String(body.contactPhone).trim()
|
||||||
|
: body.phone !== undefined
|
||||||
|
? String(body.phone).trim()
|
||||||
|
: undefined;
|
||||||
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
||||||
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
|
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
|
||||||
const benefitUsageRuleRaw =
|
const benefitUsageRuleRaw =
|
||||||
@@ -614,8 +653,8 @@ export class StoreService {
|
|||||||
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
|
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
|
||||||
|
|
||||||
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
|
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
|
||||||
if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
|
if (contactPhone !== undefined && !isStoreContactPhone(contactPhone)) {
|
||||||
throw new BadRequestException('联系电话须为11位手机号');
|
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||||
}
|
}
|
||||||
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
|
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
|
||||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||||
@@ -637,7 +676,7 @@ export class StoreService {
|
|||||||
where: { id: storeId },
|
where: { id: storeId },
|
||||||
data: {
|
data: {
|
||||||
...(name !== undefined ? { name } : {}),
|
...(name !== undefined ? { name } : {}),
|
||||||
...(phone !== undefined ? { phone } : {}),
|
...(contactPhone !== undefined ? { contactPhone } : {}),
|
||||||
...(address !== undefined
|
...(address !== undefined
|
||||||
? hasCoordsUpdate
|
? hasCoordsUpdate
|
||||||
? { address }
|
? { address }
|
||||||
@@ -1292,6 +1331,7 @@ export class StoreService {
|
|||||||
if (!url || seen.has(url)) continue;
|
if (!url || seen.has(url)) continue;
|
||||||
seen.add(url);
|
seen.add(url);
|
||||||
urls.push(url);
|
urls.push(url);
|
||||||
|
if (urls.length >= 20) break;
|
||||||
}
|
}
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -20,7 +20,8 @@
|
|||||||
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
||||||
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
||||||
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
|
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
|
||||||
| 3.4.15 | 08-06 | mini-user 门店列表卡片:去核销改箭头、地址/距离/营业时间重排、营业中角标 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
|
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
|
||||||
|
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -93,6 +94,21 @@
|
|||||||
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
|
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
|
||||||
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
|
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
|
||||||
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
|
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
|
||||||
|
| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 |
|
||||||
|
|
||||||
|
### 3.12 门店登录号与体验增量(v3.4.16)
|
||||||
|
|
||||||
|
| 字段 | 含义 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `phone` / 门店登录手机号 | 老板主账号 | 门店端短信登录;`StoreAccount.phone` |
|
||||||
|
| `contactPhone` / 联系电话 | 店长等对外号码 | C 端门店详情拨号与展示 |
|
||||||
|
|
||||||
|
- 合伙人拓店入驻、门店资料修改均须采集 **联系电话**(可与登录号不同)。
|
||||||
|
- 未填联系电话时,对外展示回退登录手机号(兼容旧数据)。
|
||||||
|
- **不**再因客户端 `validation_error` 自动创建技术支持工单。
|
||||||
|
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
|
||||||
|
- HQ 门店列表:长店名截断、操作列右固定、表过宽可横向滚动。
|
||||||
|
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
# 杜康好客 · V3.0 现状对照
|
# 杜康好客 · V3.0 现状对照
|
||||||
|
|
||||||
> 基准:仅 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · 总部 = **`admin-web`**(非 H5)
|
> 基准:仅 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · 总部 = **`admin-web`**(非 H5)
|
||||||
> 图例:✅ 完成 · 🔶 部分 · ❌ 未做 · ⚠️ 曾冲突已修
|
> 图例:✅ 完成 · 🔶 部分 · ❌ 未做 · ⚠️ 曾冲突已修
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
||||||
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
||||||
| 近期版本 | v3.4.10 套餐 · v3.4.11 开发计划/企微 · v3.4.12 工单迭代 · v3.4.13 体验优化 **已发生产** |
|
| 近期版本 | … · v3.4.15 · **v3.4.16 联系电话/套餐 UI/合伙人列表与入驻客服门槛/HQ 门店列表表格** |
|
||||||
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
|
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
|
||||||
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
|
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
|
||||||
|
|
||||||
@@ -55,16 +55,16 @@
|
|||||||
| 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ |
|
| 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ |
|
||||||
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
|
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
|
||||||
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
||||||
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
|
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
|
||||||
| 3.4.15 | [`mini-user 门店列表优化`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
|
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
|
||||||
|
|
||||||
## 5. 变更记录
|
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
|
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
|
||||||
| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
|
| 2026-08-07 | v3.4.15 增补:HQ 门店照片替换/删除、套餐多图上限 20 |
|
||||||
| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
|
|
||||||
| 2026-08-05 | v3.4.13 |
|
| 2026-08-05 | v3.4.13 |
|
||||||
| 2026-08-04 | v3.4.11 / v3.4.12 |
|
| 2026-08-04 | v3.4.11 / v3.4.12 |
|
||||||
| 2026-07-11 | 首版对照表 |
|
| 2026-07-11 | 首版对照表 |
|
||||||
|
|||||||
@@ -1,21 +1,43 @@
|
|||||||
# 杜康好客 · v3.4.15 mini-user 门店列表卡片优化
|
# 杜康好客 · v3.4.15 mini-user 门店列表 + HQ 门店照片 / 套餐多图
|
||||||
|
|
||||||
> **2026-08-06** · **开发中** · mini-user `3.4.15` · **未发版**
|
> **2026-08-07** · mini-user `3.4.15`
|
||||||
|
|
||||||
## 更新内容
|
## 更新内容
|
||||||
|
|
||||||
|
### A. mini-user 门店列表卡片
|
||||||
|
|
||||||
- 门店列表卡片去掉「去核销」按钮,整卡最右侧改为小箭头(进入详情)
|
- 门店列表卡片去掉「去核销」按钮,整卡最右侧改为小箭头(进入详情)
|
||||||
- 第 1 行:门店名单行截断(不显示省略号),宽度顶到文案区最右
|
- 第 1 行:门店名单行截断(不显示省略号),宽度顶到文案区最右
|
||||||
- 第 2 行:地址最多两行;右侧显示距离
|
- 第 2 行:地址最多两行;右侧显示距离
|
||||||
- 第 3 行:营业时间同行展示(多段时段空格拼接)
|
- 第 3 行:营业时间同行展示(多段时段空格拼接)
|
||||||
- 店铺封面右上角叠加斜角「营业中」标签图
|
- 店铺封面右上角叠加斜角「营业中」标签图
|
||||||
|
|
||||||
|
### B. HQ 门店照片支持替换与删除
|
||||||
|
|
||||||
|
- 门店详情「审核材料」Tab:门头照 / 环境照 / 签约合同可上传替换、删除
|
||||||
|
- 环境照最多 **20** 张;保存后写回 `CommonResource`(软删旧图再写入)
|
||||||
|
- `PUT /admin/stores/:id` 支持 `coverUrl` / `envPhotoUrls` / `contractUrl`(空值=删除)
|
||||||
|
|
||||||
|
### C. 门店套餐图片最多 20 张
|
||||||
|
|
||||||
|
- 单条套餐支持多图:`imageUrls`(JSON)+ `imageUrl` 同步为首图(兼容旧数据)
|
||||||
|
- 上限 `STORE_PACKAGE_IMAGE_MAX_COUNT = 20`
|
||||||
|
- HQ / 合伙人 H5 / 门店 H5 均可增删换图;C 端套餐详情左右滑动浏览并可点击预览
|
||||||
|
|
||||||
|
### D. 门店排序字段
|
||||||
|
|
||||||
|
- `store_store.sort_order`(`Store.sortOrder`,默认 0,越小越靠前)
|
||||||
|
- HQ 门店列表 / 新建 / 编辑可配置;C 端门店列表优先按 `sortOrder`,同序再按距离(有定位)或创建时间
|
||||||
|
|
||||||
## 范围
|
## 范围
|
||||||
|
|
||||||
| 项 | 交付 |
|
| 项 | 交付 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| 门店列表卡片 | 布局与交互如上 |
|
| 门店列表卡片 | 布局与交互如上 |
|
||||||
| 版本号 | `mini-user` `3.4.15`(`APP_VERSION` / package.json) |
|
| HQ 门店照片 | 审核材料可替换/删除 |
|
||||||
|
| 套餐多图 | 最多 20 张 / 条;C 端左右滑动 |
|
||||||
|
| 门店排序 | `sortOrder` 字段 + HQ 配置 + C 端排序 |
|
||||||
|
| 版本号 | `mini-user` `3.4.15` |
|
||||||
|
|
||||||
## ACC
|
## ACC
|
||||||
|
|
||||||
@@ -24,3 +46,7 @@
|
|||||||
- [ ] 地址 ≤2 行,距离在第二行右侧
|
- [ ] 地址 ≤2 行,距离在第二行右侧
|
||||||
- [ ] 营业时间单行;双时段同行显示
|
- [ ] 营业时间单行;双时段同行显示
|
||||||
- [ ] 封面右上角可见「营业中」斜角标签
|
- [ ] 封面右上角可见「营业中」斜角标签
|
||||||
|
- [ ] HQ 门店详情可替换/删除门头照、环境照、合同并保存生效
|
||||||
|
- [ ] 环境照无法超过 20 张
|
||||||
|
- [ ] 套餐单条可上传至多 20 张图;C 端详情可左右滑动浏览全部
|
||||||
|
- [ ] HQ 可设置门店排序;C 端列表按排序优先展示
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 杜康好客 · v3.4.16 门店联系电话与体验优化
|
||||||
|
|
||||||
|
> **2026-08-09** · PRD §3.12 · mini-user / h5-partner / admin-web / API
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| 项 | 交付 |
|
||||||
|
|----|------|
|
||||||
|
| A. 登录号 / 联系电话分离 | `Store.contactPhone`;合伙人入驻/编辑;HQ;C 端 `publicDial` |
|
||||||
|
| B. 取消自动技术支持工单 | `validation_error` 不再自动建 ST;上报与人工 ST 保留 |
|
||||||
|
| C. 套餐详情排版 | 套餐名 + 价格:一行能放下则同行,否则名称换行、价格次行右对齐 |
|
||||||
|
| D. 门店详情套餐列表 | 套餐名称后展示价格 |
|
||||||
|
| E. 合伙人门店列表 | 右上角状态;开关营业/临时闭店;永久闭店按钮;下拉筛选 |
|
||||||
|
| F. 入驻企微客服门槛 | HQ 可配二维码;勾选「已添加客服」后才可提交 |
|
||||||
|
| G. HQ 门店列表表格 | 店名过长截断;操作列右侧固定;横向滚动 |
|
||||||
|
|
||||||
|
## A. 登录号 ≠ 对外联系电话
|
||||||
|
|
||||||
|
| 字段 | 含义 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `phone` | 老板主账号 | 门店端登录;`StoreAccount.phone` |
|
||||||
|
| `contactPhone` | 店长/对外 | C 端拨号与展示 |
|
||||||
|
|
||||||
|
- C 端 `GET /stores`、`GET /stores/:id`:`phone` = `contactPhone ?? phone`(兼容旧小程序)
|
||||||
|
- 未填联系电话时对外回退登录号
|
||||||
|
|
||||||
|
## B. 取消 validation_error 自动 ST
|
||||||
|
|
||||||
|
- 原路径:`POST /common/client-errors` + `category=validation_error` → `createFromClientValidation`
|
||||||
|
- **删除**自动建单;保留 client-error 落库 / WeCom 告警;HQ 手动建单与企微「创建」不变
|
||||||
|
|
||||||
|
## C / D. mini-user 套餐 UI
|
||||||
|
|
||||||
|
- 套餐详情标题与门店详情「门店套餐」列表一致:名称在左可换行,价格始终居右;放不下时价格落在次行右侧
|
||||||
|
|
||||||
|
## E. 合伙人门店列表
|
||||||
|
|
||||||
|
| UI | 行为 |
|
||||||
|
|----|------|
|
||||||
|
| 右上角徽标 | 待审核 / 审核驳回 / 营业中 / 临时闭店 / 永久闭店 |
|
||||||
|
| 开关 | `OPEN` ↔ `PAUSED`;CLOSED / PENDING 禁用 |
|
||||||
|
| 永久闭店 | 独立按钮 + 确认 → `CLOSED` |
|
||||||
|
| 筛选 | 下拉:全部 / 营业中 / 临时闭店 / 待审核 / 已驳回 / 永久闭店 |
|
||||||
|
|
||||||
|
API:`PUT /partner/stores/:id/status`(不变)
|
||||||
|
|
||||||
|
## F. 入驻企微客服二维码门槛
|
||||||
|
|
||||||
|
| 配置键 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `PARTNER_ONBOARD_CS_QR_URL` | 企微客服二维码图片 URL(OSS) |
|
||||||
|
| `PARTNER_ONBOARD_CS_HINT` | 提示文案(默认见下) |
|
||||||
|
|
||||||
|
默认提示:`使用问题、提现问题等随时可联系【杜康好客】客服`
|
||||||
|
|
||||||
|
- `GET /common/client-config` 对 PARTNER 下发 `partnerOnboardCsQrUrl` / `partnerOnboardCsHint`
|
||||||
|
- HQ **系统设置 → 微信小程序配置**(与「总部客服电话」同组):可上传二维码、编辑提示文案
|
||||||
|
- 入驻信息填完后展示二维码;须勾选「我已添加【杜康好客】客服」才可提交/跳过
|
||||||
|
- **未配置二维码**:禁止提交,提示联系总部
|
||||||
|
|
||||||
|
## G. HQ 门店列表页表格体验
|
||||||
|
|
||||||
|
[`apps/admin-web` 门店列表](apps/admin-web/src/pages/StoresPage.tsx):
|
||||||
|
|
||||||
|
| 点 | 行为 |
|
||||||
|
|----|------|
|
||||||
|
| 门店名 | 过长省略号截断,悬停可看全名;「测试」标签保留 |
|
||||||
|
| 操作列 | `fixed: 'right'`,横向滚动时详情/评价始终可见 |
|
||||||
|
| 整表过宽 | `scroll.x` 开启横向滚动条(列宽合计约 1720) |
|
||||||
|
|
||||||
|
合伙人名、介绍、分类等长文本列同样启用 ellipsis,避免撑破布局。
|
||||||
|
|
||||||
|
## ACC
|
||||||
|
|
||||||
|
- [ ] 旧门店对外拨号仍可用(回退登录号或已回填 contactPhone)
|
||||||
|
- [ ] 合伙人可分别维护登录号与联系电话
|
||||||
|
- [ ] API 400 不再产生「客户端验证错误自动上报」ST
|
||||||
|
- [ ] 套餐详情短/长标题排版符合定案
|
||||||
|
- [ ] 门店详情套餐列表可见价格
|
||||||
|
- [ ] 合伙人列表状态/开关/永久闭店/下拉筛选可用
|
||||||
|
- [ ] 无二维码不可提交;有二维码须勾选后提交
|
||||||
|
- [ ] HQ 可上传/更换入驻客服二维码
|
||||||
|
- [ ] HQ 门店列表:长店名截断、操作栏右固定、可横向滚动
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 杜康好客 · v3.4.17 门店套餐审核快捷入口
|
||||||
|
|
||||||
|
> **2026-08-16** · PRD §套餐变更审核 · admin-web / API(ops)
|
||||||
|
> 目标:把「套餐变更审核」能力前置到**门店详情-套餐页签**与**门店列表操作栏**,让总部在不离开列表/详情流的情况下即可发现待审套餐并一键进入审核(含线上 vs 待审对照)。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| 项 | 交付 |
|
||||||
|
|----|------|
|
||||||
|
| A. 门店详情·套餐页签 待审提醒 | 存在 `status=PENDING` 的套餐变更时,页签顶部黄色 Alert + 「审核 / 对比」按钮 |
|
||||||
|
| B. 审核 / 对比 跳转 | 复用现有 `StorePackageAuditsPage`,跳转 `/store-package-audits?requestId=xxx` 自动打开审核抽屉(抽屉内已含线上 vs 待审并排对照) |
|
||||||
|
| C. 门店列表 操作栏快捷入口 | 有待审套餐时显示「审核套餐」「对比」两个按钮,跳转同一地址(与详情页审核功能一致) |
|
||||||
|
| D. 列表接口补 `pendingPackageAuditId` | `GET /admin/stores` 返回每家店当前待审套餐变更的 `requestId`(一次批量查询,无 schema 变更) |
|
||||||
|
| E. 审核后提醒自动刷新 | 审核完成 dispatch `admin:package-audit-changed`,套餐页签监听后刷新提醒 |
|
||||||
|
|
||||||
|
## A / B. 门店详情·套餐页签
|
||||||
|
|
||||||
|
组件:`apps/admin-web/src/components/AdminStorePackagesSection.tsx`
|
||||||
|
|
||||||
|
- 现有 `GET /admin/stores/:storeId/packages` 已返回 `pendingRequest`(含 `id`/`status`/`packages`),此前被组件丢弃;本次**捕获 `pendingRequest`** 存入 state。
|
||||||
|
- 当 `pendingRequest.status === 'PENDING'`:
|
||||||
|
- 页签顶部渲染 `<Alert type="warning">`:「该门店有待审核套餐」。
|
||||||
|
- 提醒内放「审核」「对比」两个按钮,均 `navigate('/store-package-audits?requestId=' + pendingRequest.id)`。
|
||||||
|
- 空套餐门店同样展示该提醒(不依赖 `live` 是否有数据)。
|
||||||
|
- 跳转后由 `StorePackageAuditsPage` 读取 `?requestId=` 自动打开抽屉:抽屉内左侧「线上已审核套餐」、右侧「待审核套餐」并排 `diffPackages` 对照;抽屉右上「通过 / 驳回」即审核动作。两套按钮语义:审核=处置,对比=看差异,落点同一页面。
|
||||||
|
|
||||||
|
## C. 门店列表·操作栏
|
||||||
|
|
||||||
|
组件:`apps/admin-web/src/pages/StoresPage.tsx`
|
||||||
|
|
||||||
|
- `StoreRow` 增加 `pendingPackageAuditId?: string | null`。
|
||||||
|
- 操作列(原「详情 / 评价」)在 `pendingPackageAuditId` 存在时追加「审核套餐」「对比」按钮,均 `navigate('/store-package-audits?requestId=' + row.pendingPackageAuditId)`。
|
||||||
|
- 操作列宽度 140 → 220,允许换行,避免按钮被挤压。
|
||||||
|
|
||||||
|
## D. 列表接口补 `pendingPackageAuditId`
|
||||||
|
|
||||||
|
后端:`server/dukang-api/src/modules/ops/admin-stores.service.ts` 的 `listStores`
|
||||||
|
|
||||||
|
- 取回 `items` 后,用一次查询批量取出待审变更:
|
||||||
|
`storePackageChangeRequest.findMany({ where: { storeId: { in: storeIds }, status: 'PENDING' }, select: { id, storeId } })`
|
||||||
|
- 建 `storeId -> requestId` 映射,逐店附加 `pendingPackageAuditId`(无则 `null`)。
|
||||||
|
- `mapStoreCompat` 为 `{ ...store }` 展开透传,新字段不会被丢弃;**无 Prisma schema 变更**,发布可 `--skip-db`。
|
||||||
|
|
||||||
|
## E. 审核后提醒自动刷新
|
||||||
|
|
||||||
|
- `StorePackageAuditsPage` 审核成功时调用 `notifyPackageAuditChanged()`(dispatch `admin:package-audit-changed`)。
|
||||||
|
- `AdminStorePackagesSection` 监听该事件,重新拉取 `pendingRequest` 并刷新提醒(仅更新 `pendingRequest`,不触碰正在编辑的 `items`,避免覆盖未保存套餐)。
|
||||||
|
|
||||||
|
## 关键接口 / 文件
|
||||||
|
|
||||||
|
| 位置 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /admin/stores` | 列表,新增 `pendingPackageAuditId`(ops/admin/* 透传,无 schema 变更) |
|
||||||
|
| `GET /admin/stores/:storeId/packages` | 返回 `pendingRequest`(store-package.service.ts,未改,仅前端消费) |
|
||||||
|
| `GET /admin/store-package-audits/:requestId` | 审核详情(含 `livePackages` 对照) |
|
||||||
|
| `PUT /admin/store-package-audits/:requestId/audit` | 通过 / 驳回 |
|
||||||
|
| `apps/admin-web/src/components/AdminStorePackagesSection.tsx` | 套餐页签提醒 + 按钮 |
|
||||||
|
| `apps/admin-web/src/pages/StorePackageAuditsPage.tsx` | `?requestId=` 自动开抽屉 + 变更明细(含 `fieldChanges` / `diffText` / `TextDiff`) |
|
||||||
|
| `apps/admin-web/src/pages/StoresPage.tsx` | 列表操作栏快捷入口 |
|
||||||
|
|
||||||
|
## F. 套餐变更对比·逐字段 / 逐字明细
|
||||||
|
|
||||||
|
组件:`apps/admin-web/src/pages/StorePackageAuditsPage.tsx`
|
||||||
|
|
||||||
|
> 需求:审核抽屉原本只给每条套餐「新增 / 删除 / 变更 / 未变」粗粒度标记,看不出具体哪里变了。本次在「待审核套餐」卡片底部新增**变更明细**块,逐字段列出差异;文本类字段进一步做**逐字(LCS)差异定位**,精确高亮哪些字被增 / 删。
|
||||||
|
|
||||||
|
- `fieldChanges(live, proposed)`:逐字段比对,产出 `{ label, old, now, kind }` 明细:
|
||||||
|
- `kind: 'value'`(整体替换展示 `旧 → 新`):**价格**、**图片(N 张 → M 张)**。
|
||||||
|
- `kind: 'text'`(走逐字差异):**套餐名称、菜品内容、可用时间、其他说明**。
|
||||||
|
- `diffText(a, b)`:LCS 动态规划(`dp[i][j]`)+ 回溯,产出 `equal / delete / insert` 段落并合并相邻同类型;时间/空间 `O(|a|·|b|)`,套餐字段长度可控,无性能风险。
|
||||||
|
- `TextDiff({ oldText, newText })`:渲染两行——
|
||||||
|
- 「原:」行用**红色删除线**标出被删的字(`delete` 段),其余正常;
|
||||||
|
- 「新:」行用**绿色**标出新增的字(`insert` 段),其余正常。
|
||||||
|
- 一眼定位到具体改动的字,而非整段替换。
|
||||||
|
- 抽屉顶部汇总条:**新增 X · 删除 Y · 变更 Z · 未变 W**(由 `diffPackages` 的 `changes` 统计)。
|
||||||
|
|
||||||
|
### 已知局限
|
||||||
|
- 套餐按**名称**(空则按位置)配对;若某条套餐**改名**,会误判为「删除旧名 + 新增新名」而非「变更」,改名本身不会进入逐字明细。如需把改名也识别为「变更」,需升级 `packageKey` 配对策略(名称变了但其余字段相近 → 视为变更)。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 某门店有待审套餐变更时:门店详情-套餐页签顶部出现黄色「有待审核套餐」提醒,且「审核 / 对比」可点。
|
||||||
|
- [ ] 点「审核」或「对比」均跳到套餐审核页并自动打开该门店变更抽屉,可见线上 vs 待审对照与通过/驳回。
|
||||||
|
- [ ] 门店列表操作栏对该门店出现「审核套餐」「对比」按钮,点击同样跳转并自动打开抽屉。
|
||||||
|
- [ ] 在审核页完成审核后,返回门店详情-套餐页签,提醒消失(或被事件即时刷新)。
|
||||||
|
- [ ] 无待审套餐的门店:列表与详情页签均不出现上述入口。
|
||||||
|
- [ ] 抽屉中「变更」套餐的「待审核」卡片底部出现「变更明细」:价格/图片以 `旧 → 新` 展示;菜品内容/可用时间/其他说明/套餐名称以**逐字差异**展示(原行红删、新行绿增),能精确定位到具体改动的字。
|
||||||
Reference in New Issue
Block a user