14 Commits

Author SHA1 Message Date
jacy 7304c7a8e1 chore(ops): 增加生产库备份/回滚与按 isTest 清理脚本
只入库通用运维能力,不提交写死手机号的一次性清理脚本与 dump。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 22:03:35 +08:00
jacy a3f17cbf94 v3.5.7版本迭代
CI / verify (pull_request) Waiting to run
门店端UI修改
2026-08-23 19:26:12 +08:00
jacy 114d77ca48 修改订单导出时间错误
CI / verify (pull_request) Waiting to run
2026-08-23 17:33:20 +08:00
jacy 7e12eb3ca6 v3.5.6版本迭代
CI / verify (pull_request) Waiting to run
2026-08-23 16:41:22 +08:00
jacy 608e3e5ec5 整理发布脚本 2026-08-23 14:37:36 +08:00
jacy d98b0aefb0 整理上下文 2026-08-23 14:37:29 +08:00
jacy 22cd00da42 v3.5.5版本上传
CI / verify (pull_request) Waiting to run
2026-08-23 12:26:57 +08:00
jacy f9161368e1 feat(admin): show HQ product list as SPU with expandable SKUs
Group catalog rows by product, add paid bottle sales, and keep multi-SKU fulfillment on SKU rows only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 23:52:28 +08:00
jacy 8a061f085f 提交v3.5.4 版本号小程序 2026-08-21 17:49:53 +08:00
jacy b20e566a65 logo3 2026-08-21 17:39:11 +08:00
jacy 4d434c9c67 v3.5.4 规格主图、门店使用规则与收银台 logo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 17:36:53 +08:00
jacy 62e61a9e63 v3.5.4版本提交,修改api
CI / verify (pull_request) Has been cancelled
2026-08-21 16:22:53 +08:00
jacy a01217539c v3.5.4版本提交
CI / verify (pull_request) Has been cancelled
2026-08-21 15:48:15 +08:00
jacy 26334ed072 v3.5.3版本更新2
CI / verify (pull_request) Has been cancelled
2026-08-20 20:09:05 +08:00
144 changed files with 9511 additions and 2167 deletions
-55
View File
@@ -1,55 +0,0 @@
---
description: 杜康好客全局约束 — Monorepo 布局、文档优先级、2 人 OWNER 边界
alwaysApply: true
---
# 杜康好客 · 核心规则
## Monorepo 布局
- `apps/` — preV1`h5-user`、`h5-shop`、`h5-partner`、`admin-web`
- `packages/` — `shared-types`、`domain`、`shared-ui`
- `server/dukang-api/` — NestJS 单体 API
- `pages/` — UI 原型(**只读参照**
> V2 目标为 Taro 四端(`mini-user` 等),编码以 **当前实际目录** 为准。
## 文档
- **V3.0 业务事实源**`杜康好客-v3-PRD.md`
- V3 实现验收:`杜康好客-v3编码手册.md`
- V2 蓝图:`杜康好客-V2编码手册.md`(与 V3 冲突时 V3 PRD 优先)
- preV1 裁剪:`杜康好客-preV1编码手册.md`
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
**禁止**:编码时修改业务规则;需求变更只改 `杜康好客-v3-PRD.md`。
## 边界(2 人团队)
> **临时(2026-07**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
| 负责人 | 路径 |
|--------|------|
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
| ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
- `apps/*` 禁止 import `server/*` 或其他 app
- Mock 只在 `integrations/*`,不在业务 Service 散落
## 共享契约
- 枚举/DTO → `packages/shared-types`
- 纯规则 → `packages/domain`(无 IO
## 核心业务常量
- 权益额 = `benefit_amount ?? price`
- 核销:直接核销 `0 < amount ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额`Redis 码 3 分钟
- 订单 Tab(V3.0):`待付款 | 已付款 | 已完成`
- API`/api/v1`,响应 `{ code, message, data }`
## 提交
- Conventional Commits`feat(trade):` 等
- 不提交 `.env`
+6 -28
View File
@@ -1,33 +1,11 @@
---
description: Git 提交与 PR 协作规范
description: 杜康提交 scope 与发版默认(生产)
alwaysApply: true
---
# Git 规范
# Git / 发版(杜康专属)
## 提交格式
Conventional Commitsscope = 端或模块名:
```
feat(trade): add order preview API
fix(redeem): validate redeem amount against balance
chore(shared-types): add OrderStatus enum
```
## 禁止提交
- `.env`、`.env.local` 等敏感配置
- `node_modules/`、`dist/`(已在 .gitignore
## PR 与 Review
- feature 分支 + 小步 PR
- 跨模块改动需相关双 Owner Review
- Prisma 迁移必须多 Owner Review
## Agent 行为
- **仅用户明确要求时** 才执行 git commit / push
- 不 amend 已推送的 commit,不 force push main/master/dev
- 发版:`dev`→测试(staging)`main`→生产;用户只说「发布/发版」默认发**生产**(见 dukang-release skill);明确说「发测试」才发 staging
- Conventional Commitsscope = 端或模块:`feat(trade):`、`fix(redeem):`
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- Prisma 迁移须 Review
+1 -1
View File
@@ -21,7 +21,7 @@ disable-model-invocation: true
## 会话启动(按序)
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`preV1 → preV1 手册;V2 蓝图 → V2 手册
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER **只改本 OWNER 路径**
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 段 + 对应 `apps/``server/dukang-api/AGENTS.md`**只改本 OWNER 路径**
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
5. 编码前过一遍下方 Checklist
+1
View File
@@ -5,6 +5,7 @@ description: >-
MOCK_SMS/MOCK_PAY/MOCK_DELIVERY flags, Seed data, and V2 upgrade path.
Use when working on Mock providers, feature flags, preV1 task cards P1-*,
or smoke tests — not for V2 WeChat/payment production integration.
disable-model-invocation: true
---
# 杜康好客 · preV1 Mock Skill
+1
View File
@@ -4,6 +4,7 @@ description: >-
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
or @杜康发布 / @dukang-release.
disable-model-invocation: true
---
# 杜康发布
+1
View File
@@ -4,6 +4,7 @@ description: >-
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003,
or asks to start/implement a milestone card from the coding manuals.
disable-model-invocation: true
---
# 杜康好客 · 任务卡 Skill
+4
View File
@@ -21,4 +21,8 @@ coverage/
server/dukang-api/prisma/migrations/
debug_v3.xlsx
~$debug_v3.xlsx
server/dukang-api/assets/fonts/*.ttf
server/dukang-api/assets/fonts/*.ttc
server/dukang-api/assets/fonts/*.otf
deploy/auto-release.env
deploy/backups/
+26 -150
View File
@@ -1,133 +1,37 @@
# 杜康好客 · AGENTS.md
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载
> AI 编码入口。人类读 [`agent.md`](./agent.md)。启动、端口、测试账号见 [`README.md`](./README.md)
## 项目概览
杜康酒业 O2O:**购酒 → 发券 → 门店核销**。Monorepo + NestJS 单体(`/api/v1``{ code, message, data }`)。
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
| 路径 | 说明 |
|------|------|
| `apps/` | `h5-user` · `h5-shop` · `h5-partner` · `admin-web` · `mini-user` |
| `packages/` | `shared-types`(枚举/DTO)· `domain`(纯规则,无 IO)· `shared-ui` |
| `server/dukang-api/` | 唯一 API |
| `pages/` | UI 原型,只读 |
| 阶段 | 手册 | 说明 |
|------|------|------|
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考(**已压缩**V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
前端:[`apps/AGENTS.md`](./apps/AGENTS.md)。后端模块/表 OWNER[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)。
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
## 文档(冲突时)
## 启动命令
```bash
# 基础设施
cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017
# 依赖与数据库
pnpm install
cp server/dukang-api/.env.example server/dukang-api/.env
pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端)
pnpm dev:api # http://localhost:3010/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
# 验证
node scripts/smoke-v3.mjs
node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
## 文档优先级(冲突时)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**
2. `杜康好客-v3-现状对照.md` — 实现进度审计
3. `杜康好客-v3编码手册.md` — 实现验收、核销、分工
4. `conventions.md` — 协作与模块边界
5. V2 手册 §四 §五 §六 — 仅作架构/DB/API **参考**(与 V3 冲突时忽略)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**(编码时禁止改业务规则;需求变更只改它)
2. `杜康好客-v3-现状对照.md` — 进度
3. `杜康好客-v3编码手册.md` — 验收、核销、分工
4. `conventions.md` — 协作边界
5. V2 手册 §四–§六 — 架构/DB/API **参考**(与 V3 冲突则忽略)
6. `pages/{user,shop,partner,hq}/` — UI 参照
> **V2 / preV1 手册不再作需求依据。**
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 过时文档;`apps/*` import `server/*`;跨模块 `prisma` 写他人表。V2 / preV1 手册不再作需求依据。
## 团队与 OWNER 边界(2 人
## OWNER2026-07 起暂由 jacy 代管全部
| 负责人 | Git 账号 | 职责 |
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管** |
`jacy-dukang` 可改 `apps/*``packages/*`、全部后端模块。逻辑边界仍按 A/B/C/D,便于子 Agent 隔离。Prisma 迁移由 jacy 主导。
> **临时分工(2026-07**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
- 跨模块只 inject 对方 **exported Service**
- Mock 只在 `integrations/*`;微信/配送回调只进 `callbacks/`
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下:
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|------------|--------|----------|-------------|
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
**Prisma 迁移**jacy-dukang 主导 Review;涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review)。
### 跨模块规则(R1–R8 摘要)
- 只 inject 对方 Module **exports 的 Service**,禁止 `prisma.xxx` 写他人表
- `apps/*` 禁止 import `server/*` 源码;只走 HTTP + `packages/shared-types`
- 业务纯规则 → `packages/domain`;枚举/DTO → `packages/shared-types`
- 微信/配送回调入口 **仅** `callbacks/`Mock 实现 **仅** `integrations/*`
## Cursor 规范
| 类型 | 路径 | 说明 |
|------|------|------|
| Rules | `.cursor/rules/` | 自动加载的编码规范 |
| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
| V3.0 需求 Skill | `.cursor/skills/dukang-v3/` | @dukang-v3PRD、Wave、REQ/ACC |
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用)
在 Cursor Agent 输入 `/` 选择:
| 子 Agent | 负责人 | 适用场景 |
|----------|--------|----------|
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
## Skills(按需 @
| Skill | 何时用 |
|-------|--------|
| `dukang-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
- PR 前审查:`/boundary-reviewer`
- 原型参照:`pages/{user,shop,partner}/`(只读)
## 核心业务常量(不可偏离)
## 不可偏离
```
权益额 = benefit_amount ?? price
@@ -137,38 +41,10 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
**微信小程序页面标题**weapp 只用原生 `navigationBarTitleText`,不要再画一层与导航栏重复的 `SubPageHeader` title;见 `.cursor/rules/mini-user-weapp-nav-title.mdc`
微信:iOS H5 登录后禁止仅 SPA 跳转再扫码`packages/weixin-sdk/GOTCHAS.md`)。小程序 `open-type` 按钮祖先禁止 `stopPropagation`weapp 标题只用 `navigationBarTitleText``h5-partner``admin-web` 同端口 5175,勿同时开
## 环境与发版
## 按需加载
| 环境 | 分支 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev`
- 生产发布 → `deploy/deploy-prod.sh``main`,验收后)
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example`
- 详情:`@dukang-release` / `.cursor/skills/dukang-release/SKILL.md`
## 提交与 PR
- Conventional Commits`feat(trade):``fix(redeem):`scope = 端或模块
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- 不提交 `.env``dist/``node_modules/`
## 完成定义(DoD
- [ ] 任务卡验收项全部满足
- [ ] 未跨模块直写 Prisma 表
- [ ] 枚举/DTO 在 `shared-types`;纯规则在 `domain`
- [ ] `pnpm lint` 无新增错误;相关 domain 单测通过
## 嵌套指引
- 后端细节:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)
- 前端四 App[`apps/AGENTS.md`](./apps/AGENTS.md)
- 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产)
- 子 Agent`/owner-a-user-trade` 等(见 `.cursor/agents/`
- DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
@@ -0,0 +1,616 @@
import { useMemo, useState } from 'react';
import {
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
} from 'antd';
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { request } from '../lib/api';
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
import OssUpload from './OssUpload';
type SpecValue = { id?: string; name: string; sortOrder?: number };
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
type SkuRow = {
id?: string;
specValueIds: string[];
skuCode?: string;
barcode69: string;
price: number;
benefitAmount?: number;
status: string;
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
allowOnSitePickup: boolean;
saleUnit: 'BOTTLE' | 'BOX';
bottlesPerUnit: number;
isDefault: boolean;
sortOrder?: number;
specText?: string;
imageUrl?: string | null;
};
function cartesian(attrs: SpecAttr[]): string[][] {
if (!attrs.length) return [[]];
return attrs.reduce<string[][]>(
(acc, attr) => {
const next: string[][] = [];
const values = attr.values.filter((v) => v.name?.trim());
for (const prev of acc) {
for (const v of values) {
const vid = v.id || `__new__:${attr.name}:${v.name}`;
next.push([...prev, vid]);
}
}
return next.length ? next : acc;
},
[[]],
);
}
function keyOf(ids: string[]) {
return [...ids].sort().join('_');
}
type Props = {
productId: string;
initialAttrs: SpecAttr[];
initialSkus: SkuRow[];
onSaved: () => void;
};
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
const [attrs, setAttrs] = useState<SpecAttr[]>(
initialAttrs.length
? initialAttrs
: [],
);
const [skus, setSkus] = useState<SkuRow[]>(
initialSkus.length
? initialSkus.map((s) => ({
...s,
specValueIds: s.specValueIds ?? [],
status: s.status || 'ON_SALE',
allowOnlinePurchase: s.allowOnlinePurchase !== false,
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
allowOnSitePickup: !!s.allowOnSitePickup,
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
isDefault: !!s.isDefault,
imageUrl: s.imageUrl ?? '',
}))
: [
{
specValueIds: [],
barcode69: '',
price: 0,
status: 'ON_SALE',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
imageUrl: '',
},
],
);
const [saving, setSaving] = useState(false);
const [editIndex, setEditIndex] = useState<number | null>(null);
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
function regenerateMatrix() {
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
const next: SkuRow[] = combos.map((ids, i) => {
const existing = byKey.get(keyOf(ids));
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
return {
specValueIds: ids,
barcode69: '',
price: skus[0]?.price ?? 0,
benefitAmount: skus[0]?.benefitAmount,
status: 'DRAFT',
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
allowOnSitePickup: false,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: i === 0,
sortOrder: i,
imageUrl: '',
};
});
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
setSkus(next.length ? next : skus);
}
function patchSku(index: number, patch: Partial<SkuRow>) {
setSkus((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...patch };
return next;
});
}
async function handleSave() {
setSaving(true);
try {
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
const specsRes = await request<{
specAttrs: SpecAttr[];
skus: SkuRow[];
}>(`/admin/products/${productId}/specs`, {
method: 'PUT',
body: JSON.stringify({
attrs: attrs.map((a, i) => ({
id: a.id,
name: a.name.trim(),
sortOrder: i,
values: a.values
.filter((v) => v.name.trim())
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
})),
}),
});
// 映射临时 id → 真实 id
const nameToId = new Map<string, string>();
for (const a of specsRes.specAttrs ?? []) {
for (const v of a.values ?? []) {
nameToId.set(`${a.name}:${v.name}`, v.id!);
}
}
const resolveIds = (ids: string[]) =>
ids.map((id) => {
if (!id.startsWith('__new__:')) return id;
const [, attrName, valueName] = id.split(':');
const real = nameToId.get(`${attrName}:${valueName}`);
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
return real;
});
const payloadSkus = skus.map((s, i) => ({
id: s.id,
specValueIds: resolveIds(s.specValueIds ?? []),
barcode69: s.barcode69.trim(),
price: s.price,
benefitAmount: s.benefitAmount,
status: s.status,
allowOnlinePurchase: s.allowOnlinePurchase,
allowCrossCityDelivery: s.allowCrossCityDelivery,
allowOnSitePickup: s.allowOnSitePickup,
saleUnit: s.saleUnit,
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
isDefault: !!s.isDefault,
sortOrder: i,
imageUrl: s.imageUrl?.trim() || null,
}));
const barcodes = payloadSkus.map((row) => row.barcode69);
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
if (new Set(barcodes).size !== barcodes.length) {
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
}
await request(`/admin/products/${productId}/skus`, {
method: 'PUT',
body: JSON.stringify({ skus: payloadSkus }),
});
message.success('规格与 SKU 已保存');
onSaved();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
const editing = editIndex != null ? skus[editIndex] : null;
return (
<div>
<Typography.Paragraph type="secondary">
SKU DK <strong></strong> 69
SKU
</Typography.Paragraph>
<Typography.Title level={5}></Typography.Title>
{attrs.map((attr, ai) => (
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Space align="start" style={{ width: '100%' }} wrap>
<Input
style={{ width: 140 }}
placeholder="规格名"
value={attr.name}
onChange={(e) => {
const next = [...attrs];
next[ai] = { ...attr, name: e.target.value };
setAttrs(next);
}}
/>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
/>
</Space>
<div style={{ marginTop: 8 }}>
{attr.values.map((val, vi) => (
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
<Input
placeholder="规格值"
value={val.name}
onChange={(e) => {
const next = [...attrs];
const values = [...attr.values];
values[vi] = { ...val, name: e.target.value };
next[ai] = { ...attr, values };
setAttrs(next);
}}
/>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => {
const next = [...attrs];
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
setAttrs(next);
}}
/>
</Space>
))}
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={() => {
const next = [...attrs];
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
setAttrs(next);
}}
>
</Button>
</div>
</div>
))}
<Space style={{ marginBottom: 16 }}>
<Button
icon={<PlusOutlined />}
disabled={attrs.length >= 3}
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
>
</Button>
<Button onClick={regenerateMatrix}> SKU </Button>
</Space>
<Typography.Title level={5}>SKU </Typography.Title>
<Table
size="small"
rowKey={(_, i) => String(i)}
pagination={false}
scroll={{ x: 1240 }}
dataSource={skus}
columns={[
{
title: '规格',
width: 140,
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
},
{
title: 'SKU',
width: 110,
render: (_, row) => (
<Typography.Text type="secondary">
{row.skuCode || '保存后自动生成'}
</Typography.Text>
),
},
{
title: <span>69 <Typography.Text type="danger">*</Typography.Text></span>,
width: 180,
render: (_, row, index) => (
<Input
placeholder="本规格独立 69 码"
value={row.barcode69}
onChange={(e) => {
const next = [...skus];
next[index] = { ...row, barcode69: e.target.value };
setSkus(next);
}}
/>
),
},
{
title: '售价',
width: 100,
render: (_, row, index) => (
<InputNumber
min={0}
style={{ width: '100%' }}
value={row.price}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, price: Number(v) || 0 };
setSkus(next);
}}
/>
),
},
{
title: '权益',
width: 100,
render: (_, row, index) => (
<InputNumber
min={0}
style={{ width: '100%' }}
value={row.benefitAmount}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
setSkus(next);
}}
/>
),
},
{
title: '状态',
width: 110,
render: (_, row, index) => (
<Select
style={{ width: '100%' }}
value={row.status}
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={(status) => {
const next = [...skus];
next[index] = { ...row, status };
setSkus(next);
}}
/>
),
},
{
title: '单位',
width: 160,
render: (_, row, index) => (
<Space>
<Select
style={{ width: 80 }}
value={row.saleUnit}
options={[
{ value: 'BOTTLE', label: '瓶' },
{ value: 'BOX', label: '箱' },
]}
onChange={(saleUnit) => {
const next = [...skus];
next[index] = {
...row,
saleUnit,
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
};
setSkus(next);
}}
/>
{row.saleUnit === 'BOX' ? (
<InputNumber
min={1}
style={{ width: 70 }}
value={row.bottlesPerUnit}
onChange={(v) => {
const next = [...skus];
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
setSkus(next);
}}
/>
) : null}
</Space>
),
},
{
title: '履约',
width: 200,
render: (_, row, index) => (
<Space direction="vertical" size={0}>
<Switch
checkedChildren="线上"
unCheckedChildren="线上"
checked={row.allowOnlinePurchase}
onChange={(checked) => {
const next = [...skus];
next[index] = {
...row,
allowOnlinePurchase: checked,
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
};
setSkus(next);
}}
/>
<Switch
checkedChildren="跨城"
unCheckedChildren="跨城"
disabled={!row.allowOnlinePurchase}
checked={row.allowCrossCityDelivery}
onChange={(checked) => {
const next = [...skus];
next[index] = { ...row, allowCrossCityDelivery: checked };
setSkus(next);
}}
/>
<Switch
checkedChildren="现场"
unCheckedChildren="现场"
checked={row.allowOnSitePickup}
onChange={(checked) => {
const next = [...skus];
next[index] = { ...row, allowOnSitePickup: checked };
setSkus(next);
}}
/>
</Space>
),
},
{
title: '默认',
width: 70,
render: (_, row, index) => (
<Radio
checked={row.isDefault}
onChange={() => {
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
}}
/>
),
},
{
title: '主图',
width: 72,
render: (_, row) =>
row.imageUrl ? (
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '编辑',
width: 80,
fixed: 'right',
render: (_, row, index) => (
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
</Button>
),
},
]}
/>
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
SKU
</Button>
<Modal
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
open={editIndex != null}
onCancel={() => setEditIndex(null)}
onOk={() => setEditIndex(null)}
okText="完成"
width={560}
destroyOnClose
>
{editing && editIndex != null ? (
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<div>
<Typography.Text type="secondary">SKU</Typography.Text>
<div>{editing.skuCode || '保存后自动生成'}</div>
</div>
<div>
<Typography.Text>69 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
<Input
placeholder="本规格独立 69 码"
value={editing.barcode69}
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
/>
</div>
<div>
<Typography.Text></Typography.Text>
<OssUpload
bizType="COVER"
value={editing.imageUrl || ''}
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
/>
</div>
<Space>
<div>
<Typography.Text></Typography.Text>
<InputNumber
min={0}
style={{ width: 140 }}
value={editing.price}
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
/>
</div>
<div>
<Typography.Text></Typography.Text>
<InputNumber
min={0}
style={{ width: 140 }}
value={editing.benefitAmount}
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
/>
</div>
</Space>
<div>
<Typography.Text></Typography.Text>
<Select
style={{ width: '100%' }}
value={editing.status}
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={(status) => patchSku(editIndex, { status })}
/>
</div>
<Space>
<Select
style={{ width: 100 }}
value={editing.saleUnit}
options={[
{ value: 'BOTTLE', label: '瓶' },
{ value: 'BOX', label: '箱' },
]}
onChange={(saleUnit) =>
patchSku(editIndex, {
saleUnit,
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
})
}
/>
{editing.saleUnit === 'BOX' ? (
<InputNumber
min={1}
value={editing.bottlesPerUnit}
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
/>
) : null}
</Space>
<Space>
<Switch
checkedChildren="线上"
unCheckedChildren="线上"
checked={editing.allowOnlinePurchase}
onChange={(checked) =>
patchSku(editIndex, {
allowOnlinePurchase: checked,
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
})
}
/>
<Switch
checkedChildren="跨城"
unCheckedChildren="跨城"
disabled={!editing.allowOnlinePurchase}
checked={editing.allowCrossCityDelivery}
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
/>
<Switch
checkedChildren="现场"
unCheckedChildren="现场"
checked={editing.allowOnSitePickup}
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
/>
<Switch
checkedChildren="默认"
unCheckedChildren="默认"
checked={editing.isDefault}
onChange={(checked) => {
if (!checked) return;
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
}}
/>
</Space>
</Space>
) : null}
</Modal>
</div>
);
}
@@ -51,6 +51,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState<string>();
const [skuId, setSkuId] = useState<string>();
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
@@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
}, [open]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const selectedSku =
skuOptions.find((s) => s.id === skuId) ||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
skuOptions[0];
const allowOnline = selectedSku
? selectedSku.allowOnlinePurchase !== false
: selectedProduct
? selectedProduct.allowOnlinePurchase !== false
: true;
const allowOnSite = selectedSku
? !!selectedSku.allowOnSitePickup
: !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedSku
? selectedSku.allowCrossCityDelivery !== false
: selectedProduct
? selectedProduct.allowCrossCityDelivery !== false
: true;
useEffect(() => {
if (!open || !selectedProduct) return;
const def =
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
skuOptions.find((s) => s.isDefault) ||
skuOptions[0];
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
setSkuId(def.id);
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, productId, selectedProduct?.id]);
useEffect(() => {
if (!open || !selectedProduct) return;
@@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId,
quantity,
deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
@@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
.finally(() => setPreviewLoading(false));
}, 300);
return () => window.clearTimeout(timer);
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
useEffect(() => () => stopPoll(), []);
@@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
setProductId(undefined);
setSkuId(undefined);
setPromoCodeId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
@@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId: productId!,
quantity,
promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
};
setSubmitting(true);
@@ -317,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
loading={loadingOptions}
placeholder="选择商品"
value={productId}
onChange={setProductId}
onChange={(id) => {
setProductId(id);
setSkuId(undefined);
}}
options={(options?.products ?? []).map((p) => ({
value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
@@ -325,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
/>
</Form.Item>
<Form.Item label="数量" required>
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
<Form.Item label="规格" required>
<Select
placeholder="选择规格"
value={skuId}
onChange={(id) => {
setSkuId(id);
const sku = skuOptions.find((s) => s.id === id);
if (sku?.saleUnit === 'BOX') setQuantity(1);
}}
options={skuOptions.map((s) => ({
value: s.id,
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
<InputNumber
min={1}
value={quantity}
@@ -23,6 +23,11 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
cityName?: string;
address?: string;
partnerAccount?: { companyName?: string };
primaryAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
}
| undefined;
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
@@ -46,6 +51,15 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
</Descriptions.Item>
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="收款户名">
{store?.primaryAccount?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="收款账号">
{store?.primaryAccount?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{store?.primaryAccount?.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
@@ -174,11 +174,13 @@ function PackageDetailCard({
pkg,
change,
changes,
imageSize = 72,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
change?: keyof typeof CHANGE_LABELS;
changes?: FieldChange[];
imageSize?: number;
}) {
const images = normalizeStorePackageImageUrls(pkg);
const meta = change ? CHANGE_LABELS[change] : null;
@@ -217,7 +219,7 @@ function PackageDetailCard({
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image key={url} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={url} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -265,6 +267,7 @@ export type StorePackageAuditPanelProps = {
requestId: string;
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
showActions?: boolean;
fullscreen?: boolean;
onAudited?: () => void;
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
};
@@ -273,6 +276,7 @@ export type StorePackageAuditPanelProps = {
export default function StorePackageAuditPanel({
requestId,
showActions = true,
fullscreen = false,
onAudited,
onDetailLoaded,
}: StorePackageAuditPanelProps) {
@@ -389,6 +393,7 @@ export default function StorePackageAuditPanel({
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
@@ -407,6 +412,7 @@ export default function StorePackageAuditPanel({
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
+2 -2
View File
@@ -196,10 +196,10 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/benefit/ledgers': 'benefit',
'/redeem-records': 'benefit',
'/redeem-pending': 'benefit',
'/redeem/debug': 'benefit',
'/redeem/debug': 'benefit_debug',
'deliveries-group': 'deliveries',
'/deliveries': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries_debug',
'/tickets': 'tickets',
'/tickets/support': 'tech_support',
'dev-plan-group': 'dev_plan',
+12
View File
@@ -195,6 +195,18 @@ export type AdminOrderRow = {
quantity?: number;
receiverName: string;
receiverPhone: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
receiverAddress?: string;
benefitAmount?: number;
benefitCoupon?: {
couponNo?: string;
totalAmount?: number;
usedAmount?: number;
balance?: number;
status?: string;
} | null;
createdAt: string;
cityId?: string;
city?: { id: string; name: string; code: string };
+16
View File
@@ -8,3 +8,19 @@ export function downloadExcelCsv(csv: string, filename: string) {
a.click();
URL.revokeObjectURL(url);
}
/** Download binary file from base64 payload returned by HQ export APIs. */
export function downloadBase64File(contentBase64: string, filename: string, mimeType: string) {
const binary = atob(contentBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
+101 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import {
Button,
Form,
Image,
Input,
Modal,
Popconfirm,
@@ -18,6 +19,7 @@ import {
DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto,
type DevPlanTaskExportFormat,
type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto,
type DevPlanVersionDto,
@@ -25,7 +27,9 @@ import {
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
value: v,
@@ -56,6 +60,8 @@ export default function DevPlanTasksPage() {
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false);
const [exportFormat, setExportFormat] = useState<DevPlanTaskExportFormat>('xlsx');
const [exporting, setExporting] = useState(false);
const [form] = Form.useForm();
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
@@ -88,24 +94,30 @@ export default function DevPlanTasksPage() {
function openCreate() {
setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined, attachmentUrls: [''] });
setModalOpen(true);
}
function openEdit(row: DevPlanTaskDto) {
setEditing(row);
form.setFieldsValue({ content: row.content, type: row.type, status: row.status });
form.setFieldsValue({
content: row.content,
type: row.type,
status: row.status,
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
});
setModalOpen(true);
}
async function save() {
const values = await form.validateFields();
const attachmentUrls = (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean);
setSaving(true);
try {
if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(values),
body: JSON.stringify({ ...values, attachmentUrls }),
});
message.success('已更新');
} else {
@@ -115,6 +127,7 @@ export default function DevPlanTasksPage() {
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
attachmentUrls,
}),
});
message.success('已创建');
@@ -199,6 +212,34 @@ export default function DevPlanTasksPage() {
}
}
async function exportTasks(scope: 'filter' | 'selected') {
setExporting(true);
try {
const result = await request<{
filename: string;
mimeType: string;
contentBase64: string;
count: number;
}>('/admin/dev-plan/tasks/export', {
method: 'POST',
body: JSON.stringify({
scope,
format: exportFormat,
ids: scope === 'selected' ? selectedRowKeys : undefined,
status: filters.status || undefined,
type: filters.type || undefined,
keyword: filters.keyword || undefined,
}),
});
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条任务`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(false);
}
}
const columns: ColumnsType<DevPlanTaskDto> = [
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
{
@@ -216,6 +257,22 @@ export default function DevPlanTasksPage() {
),
},
{ title: '内容', dataIndex: 'content', ellipsis: true },
{
title: '附件',
width: 100,
render: (_, row) =>
row.attachmentUrls?.length ? (
<Image.PreviewGroup>
<Space size={4}>
{row.attachmentUrls.slice(0, 3).map((url) => (
<Image key={url} src={url} width={32} height={32} style={{ objectFit: 'cover' }} />
))}
</Space>
</Image.PreviewGroup>
) : (
'—'
),
},
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -243,7 +300,24 @@ export default function DevPlanTasksPage() {
<Typography.Title level={4} style={{ margin: 0 }}>
·
</Typography.Title>
<Space>
<Space wrap>
<Select
value={exportFormat}
style={{ width: 120 }}
onChange={setExportFormat}
options={[
{ value: 'markdown', label: 'Markdown' },
{ value: 'docx', label: 'Word' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'pdf', label: 'PDF' },
]}
/>
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
</Button>
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
</Button>
@@ -333,6 +407,29 @@ export default function DevPlanTasksPage() {
/>
</Form.Item>
)}
<Form.Item label="附件图片">
<Form.List name="attachmentUrls">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 ? (
<Button type="link" danger onClick={() => remove(field.name)}>
</Button>
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block>
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form>
</Modal>
+66 -3
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Descriptions,
@@ -16,9 +17,11 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
INVOICE_CATEGORY_LABELS,
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceCategory,
type InvoiceKind,
type InvoiceStatus,
type InvoiceTitleType,
@@ -34,6 +37,7 @@ type Row = {
orderNo?: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
status: InvoiceStatus;
overdue?: boolean;
@@ -53,6 +57,7 @@ type CreateFormValues = {
orderNo: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
taxNo?: string;
addressPhone?: string;
@@ -63,12 +68,22 @@ type CreateFormValues = {
};
export default function InvoicesPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('status')?.trim() || '';
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
const [filterForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
if (initialStatus) init.status = initialStatus;
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/invoices',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
return qs;
},
[filters],
@@ -81,12 +96,29 @@ export default function InvoicesPage() {
const [createForm] = Form.useForm<CreateFormValues>();
const invoiceKind = Form.useWatch('invoiceKind', createForm);
const titleType = Form.useWatch('titleType', createForm);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
filterForm.setFieldsValue({
status: filters.status || undefined,
invoiceNo: filters.invoiceNo || undefined,
});
}, [filterForm, filters.invoiceNo, filters.status]);
async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`));
setDrawerOpen(true);
}
useEffect(() => {
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.invoiceNo) === initialInvoiceNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialInvoiceNo, loading]);
async function issueWithFile(file: File) {
if (!detail) return false;
setUploading(true);
@@ -128,6 +160,7 @@ export default function InvoicesPage() {
orderNo: values.orderNo.trim(),
titleType: values.titleType,
invoiceKind: values.invoiceKind,
invoiceCategory: values.invoiceCategory,
titleName: values.titleName.trim(),
taxNo: values.taxNo?.trim() || undefined,
addressPhone: values.addressPhone?.trim() || undefined,
@@ -158,9 +191,17 @@ export default function InvoicesPage() {
},
{
title: '票种',
width: 120,
width: 140,
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
},
{
title: '类型',
width: 88,
render: (_, r) =>
r.invoiceCategory
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
: '—',
},
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
{
title: '状态',
@@ -205,6 +246,7 @@ export default function InvoicesPage() {
createForm.setFieldsValue({
titleType: 'PERSONAL',
invoiceKind: 'NORMAL',
invoiceCategory: 'LIQUOR',
});
setCreateOpen(true);
}}
@@ -213,6 +255,7 @@ export default function InvoicesPage() {
</Button>
</div>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
@@ -231,6 +274,9 @@ export default function InvoicesPage() {
]}
/>
</Form.Item>
<Form.Item name="invoiceNo" label="申请单号">
<Input allowClear placeholder="发票申请单号" />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
@@ -292,6 +338,11 @@ export default function InvoicesPage() {
<Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]}
</Descriptions.Item>
<Descriptions.Item label="类型">
{detail.invoiceCategory
? INVOICE_CATEGORY_LABELS[detail.invoiceCategory]
: '—'}
</Descriptions.Item>
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
@@ -350,6 +401,18 @@ export default function InvoicesPage() {
}}
/>
</Form.Item>
<Form.Item
name="invoiceCategory"
label="类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select
options={(Object.keys(INVOICE_CATEGORY_LABELS) as InvoiceCategory[]).map((k) => ({
value: k,
label: INVOICE_CATEGORY_LABELS[k],
}))}
/>
</Form.Item>
<Form.Item
name="titleType"
label="抬头类型"
@@ -44,6 +44,13 @@ type BillRow = {
settlementMethod: string;
status: string;
paidAt?: string | null;
paymentRef?: string | null;
fulfillmentProvider?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
};
type BillItem = {
@@ -140,14 +147,30 @@ export default function LogisticsBillsPage() {
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认结算',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
@@ -217,6 +240,7 @@ export default function LogisticsBillsPage() {
}
const summary = data?.summary;
const providerBank = (r: BillRow) => r.fulfillmentProvider;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
@@ -248,6 +272,18 @@ export default function LogisticsBillsPage() {
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, r) => providerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -550,7 +586,31 @@ export default function LogisticsBillsPage() {
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{providerBank(detail) ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="户名">
{providerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户银行">
{providerBank(detail)?.bankName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户支行">
{providerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{providerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Table
rowKey="id"
size="small"
+322 -119
View File
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Col,
Collapse,
DatePicker,
Descriptions,
Drawer,
Form,
@@ -12,6 +14,7 @@ import {
InputNumber,
Modal,
Radio,
Row,
Select,
Space,
Table,
@@ -20,7 +23,10 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { downloadBase64File } from '../lib/exportExcel';
import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
@@ -130,6 +136,110 @@ type OrderDetail = AdminOrderRow & {
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
type OrderExportScope = 'filter' | 'selected';
type OrderExportFormat = 'xlsx' | 'pdf';
type OrderExportFilters = {
orderNo?: string;
status?: string;
orderType?: string;
cityId?: string;
receiverPhone?: string;
fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string;
dateRange?: [Dayjs, Dayjs];
};
type OrderExportResult = {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
};
const ORDER_TYPE_OPTIONS = Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => ({ value, label }));
const DELIVERY_TYPE_OPTIONS = [
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
];
function isOnSitePickupOrder(row: Pick<AdminOrderRow, 'deliveryType' | 'receiverProvince' | 'receiverCity' | 'receiverAddress'>) {
return (
row.deliveryType === 'ON_SITE_PICKUP' ||
row.receiverAddress === '现场取货' ||
row.receiverAddress === '现场提货' ||
(row.receiverProvince === '现场' && row.receiverCity === '现场')
);
}
function formatReceiverAddress(row: AdminOrderRow) {
if (isOnSitePickupOrder(row)) return '现场取货';
const region = [row.receiverProvince, row.receiverCity, row.receiverDistrict].filter(Boolean).join('');
const detail = row.receiverAddress || '';
if (!region) return detail;
if (!detail || detail.startsWith(region)) return detail || region;
return `${region}${detail}`;
}
function datePresetRange(kind: 'day' | 'week' | 'month' | 'quarter' | 'year'): [Dayjs, Dayjs] {
const now = dayjs();
if (kind === 'day') return [now.startOf('day'), now.endOf('day')];
if (kind === 'week') {
const monday = now.startOf('day').subtract((now.day() + 6) % 7, 'day');
return [monday, monday.add(6, 'day').endOf('day')];
}
if (kind === 'month') return [now.startOf('month'), now.endOf('month')];
if (kind === 'quarter') {
const start = now.month(Math.floor(now.month() / 3) * 3).startOf('month');
return [start, start.add(2, 'month').endOf('month')];
}
return [now.startOf('year'), now.endOf('year')];
}
const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year'; label: string }> = [
{ key: 'day', label: '当日' },
{ key: 'week', label: '当周' },
{ key: 'month', label: '当月' },
{ key: 'quarter', label: '当季' },
{ key: 'year', label: '当年' },
];
function formatBenefitBrief(row: AdminOrderRow) {
const coupon = row.benefitCoupon;
if (coupon) {
return `总额¥${Number(coupon.totalAmount ?? 0).toFixed(0)} / 已用¥${Number(coupon.usedAmount ?? 0).toFixed(0)} / 余¥${Number(coupon.balance ?? 0).toFixed(0)}`;
}
if (row.benefitAmount != null) return `¥${Number(row.benefitAmount).toFixed(0)}`;
return '—';
}
function buildExportPayload(
scope: OrderExportScope,
format: OrderExportFormat,
filters: OrderExportFilters,
selectedIds: string[],
) {
const payload: Record<string, unknown> = { scope, format };
if (scope === 'selected') {
payload.ids = selectedIds;
return payload;
}
if (filters.orderNo) payload.orderNo = filters.orderNo;
if (filters.status) payload.status = filters.status;
if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.excludeTest) payload.excludeTest = true;
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
return payload;
}
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
if (detail.items?.length) return detail.items;
if (!detail.productName) return [];
@@ -162,6 +272,8 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
}
export default function OrdersPage() {
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const [form] = Form.useForm();
const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm();
@@ -172,6 +284,7 @@ export default function OrdersPage() {
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<OrderDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const deepLinkOpenedRef = useRef(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
@@ -189,8 +302,11 @@ export default function OrdersPage() {
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -201,6 +317,21 @@ export default function OrdersPage() {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
if (initialOrderNo) {
form.setFieldsValue({ orderNo: initialOrderNo });
}
}, [form, initialOrderNo]);
useEffect(() => {
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.orderNo) === initialOrderNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialOrderNo, loading]);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
@@ -220,6 +351,7 @@ export default function OrdersPage() {
try {
const values = form.getFieldsValue();
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status);
if (values.orderType) qs.set('orderType', values.orderType);
@@ -227,12 +359,15 @@ export default function OrdersPage() {
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [form, page, pageSize]);
}, [form, page, pageSize, initialOrderNo]);
useEffect(() => {
void load();
@@ -433,79 +568,93 @@ export default function OrdersPage() {
}
}
async function submitExport(scope: OrderExportScope) {
const values = form.getFieldsValue() as OrderExportFilters;
if (scope === 'selected' && !selectedRowKeys.length) {
message.warning('请先勾选要导出的订单');
return;
}
setExporting(true);
try {
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
const result = await request<OrderExportResult>('/admin/orders/export', {
method: 'POST',
body: JSON.stringify(payload),
});
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条订单`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(false);
}
}
const columns: ColumnsType<AdminOrderRow> = [
{
title: '订单号',
dataIndex: 'orderNo',
width: 200,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
title: '商品',
width: 240,
render: (_, row) => (
<div>
<Space size={4} wrap>
<span>{row.productName || '—'}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple"></Tag>
) : null}
</Space>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{[row.productSpec, row.quantity != null ? `×${row.quantity}` : null]
.filter(Boolean)
.join(' ')}
</Typography.Text>
</div>
</div>
),
},
{
title: '城市',
width: 90,
render: (_, row) => row.city?.name || '—',
title: '状态 / 实付',
width: 130,
render: (_, row) => (
<div>
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}>
{ORDER_STATUS_LABELS[row.status] || row.status}
</Tag>
<div>¥{row.payAmount}</div>
</div>
),
},
{
title: '商品',
width: 180,
ellipsis: true,
title: '好客权益',
width: 200,
render: (_, row) => formatBenefitBrief(row),
},
{
title: '收货信息',
width: 240,
render: (_, row) => {
const name = row.productName || '—';
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
const address = formatReceiverAddress(row);
return (
<span title={row.productSpec ? `${name}${row.productSpec}` : name}>
{name}{qty}
</span>
<div>
<div>
{row.receiverName || '—'} {row.receiverPhone || ''}
</div>
{address ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{address}
</Typography.Text>
) : null}
</div>
);
},
},
{
title: '状态',
dataIndex: 'status',
width: 140,
render: (s, row) => (
<Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple" title={row.proxyPartnerName || undefined}>
</Tag>
) : null}
</Space>
),
},
{
title: '配送',
dataIndex: 'deliveryType',
width: 90,
render: (v) =>
v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城',
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
{ title: '手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户',
dataIndex: ['user', 'userNo'],
width: 110,
render: (_, row) => row.user?.userNo || '—',
},
{
title: '快递',
width: 100,
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
},
{
title: '下单时间',
dataIndex: 'createdAt',
width: 170,
render: (v) => new Date(v).toLocaleString('zh-CN'),
render: fmtTime,
},
{
title: '操作',
@@ -539,24 +688,15 @@ export default function OrdersPage() {
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
<Space size={12}>
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
</Button>
) : null}
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
</Space>
@@ -574,54 +714,115 @@ export default function OrdersPage() {
/>
) : null}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号">
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="orderType" label="类型">
<Select
allowClear
style={{ width: 120 }}
placeholder="全部"
options={[
{ value: 'NORMAL', label: '普通订单' },
{ value: 'PROXY', label: '代下单' },
]}
/>
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="收货手机">
<Input allowClear />
</Form.Item>
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
<Select
allowClear
style={{ width: 140 }}
placeholder="全部"
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<Row gutter={[16, 8]}>
<Col xs={24} md={14} lg={12}>
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
</Form.Item>
<Space size={8} wrap>
{DATE_PRESETS.map((preset) => (
<Button
key={preset.key}
size="small"
onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
>
{preset.label}
</Button>
))}
</Space>
</Space>
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
<Select
allowClear
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={6}>
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
<Input allowClear placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={8} lg={8}>
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
<Space size={16} wrap>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
</Space>
</Form.Item>
</Col>
</Row>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
{canExportOrders ? (
<Space size={12} wrap>
<Radio.Group
optionType="button"
buttonStyle="solid"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<Radio.Button value="xlsx">Excel</Radio.Button>
<Radio.Button value="pdf">PDF</Radio.Button>
</Radio.Group>
<Button
disabled={!selectedRowKeys.length}
loading={exporting}
onClick={() => void submitExport('selected')}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
<Button loading={exporting} onClick={() => void submitExport('filter')}>
</Button>
</Space>
) : <span />}
<Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</Form.Item>
</div>
</Form>
<Table
@@ -629,16 +830,18 @@ export default function OrdersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
rowSelection={canDeleteOrders ? {
scroll={{ x: 1100 }}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
} : undefined}
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
+113 -7
View File
@@ -3,6 +3,8 @@ import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
Modal,
@@ -31,8 +33,22 @@ type Row = {
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
paymentRef?: string | null;
paidAt?: string | null;
partner?: {
companyName?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
partnerAccount?: {
companyName?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
};
type PartnerOption = { id: string; companyName: string; phone?: string };
@@ -77,6 +93,8 @@ export default function PartnerBillsPage() {
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -127,17 +145,30 @@ export default function PartnerBillsPage() {
}
function markPaid(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body });
} else {
await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST',
@@ -182,6 +213,12 @@ export default function PartnerBillsPage() {
}
}
async function openDetail(id: string) {
const d = await request<Row>(`/admin/partner-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
@@ -201,6 +238,7 @@ export default function PartnerBillsPage() {
const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
@@ -238,6 +276,18 @@ export default function PartnerBillsPage() {
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -259,6 +309,9 @@ export default function PartnerBillsPage() {
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
@@ -465,6 +518,59 @@ export default function PartnerBillsPage() {
</Button>
</Form>
</Modal>
<Drawer
title="合伙人账单明细"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={560}
>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="订单佣金">
¥{Number(detail.orderCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销佣金">
¥{Number(detail.redeemCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="应付合计">
¥{Number(detail.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLORS[detail.status] || 'default'}>
{STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{partnerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{partnerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{partnerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div>
);
}
+226 -58
View File
@@ -12,6 +12,7 @@ import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form';
type ProductDetailContentDto = {
@@ -20,6 +21,22 @@ type ProductDetailContentDto = {
features?: Array<{ icon: string; title: string; desc: string }>;
};
type SkuListRow = {
id: string;
skuCode: string;
barcode69: string;
specText: string;
price: number;
benefitAmount: number;
status: string;
isDefault?: boolean;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
soldBottles?: number;
virtual?: boolean;
};
type Row = {
id: string;
skuCode: string;
@@ -29,9 +46,15 @@ type Row = {
aromaType: string;
spec: string;
price: number;
priceMin?: number;
priceMax?: number;
soldBottles?: number;
benefitAmount: number;
status: string;
sortOrder: number;
skuCount?: number;
specEnabled?: boolean;
skus?: SkuListRow[];
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
@@ -213,7 +236,33 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
);
}
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
function FulfillmentTags(row: {
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
allowOnSitePickup?: boolean;
}) {
return (
<Space size={[0, 4]} wrap>
{row.allowOnlinePurchase !== false ? <Tag color="blue">线</Tag> : null}
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
<Tag color="cyan"></Tag>
) : null}
{row.allowOnSitePickup ? <Tag color="green"></Tag> : null}
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag></Tag> : null}
</Space>
);
}
function BaseInfoFields({
mode,
form,
hideFulfillment,
}: {
mode: 'create' | 'edit';
form: FormInstance;
/** 多规格商品:履约只在规格 SKU 上编辑 */
hideFulfillment?: boolean;
}) {
return (
<>
{mode === 'create' && (
@@ -221,8 +270,13 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" />
</Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
<Input />
<Form.Item
name="barcode69"
label="69码"
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
>
<Input placeholder="默认规格 69 码" />
</Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
@@ -250,39 +304,47 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="allowOnlinePurchase"
label="允许线上购买"
valuePropName="checked"
extra="配送到址(同城)"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
{() => (
{hideFulfillment ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
线 / / SKU
</Typography.Paragraph>
) : (
<>
<Form.Item
name="allowCrossCityDelivery"
label="允许跨城配送"
name="allowOnlinePurchase"
label="允许线上购买"
valuePropName="checked"
extra="须先开启线上购买"
extra="配送到址(同城)"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
disabled={!form.getFieldValue('allowOnlinePurchase')}
onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/>
</Form.Item>
)}
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
{() => (
<Form.Item
name="allowCrossCityDelivery"
label="允许跨城配送"
valuePropName="checked"
extra="须先开启线上购买"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
disabled={!form.getFieldValue('allowOnlinePurchase')}
/>
</Form.Item>
)}
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
</>
)}
<VisibilityWhitelistFields form={form} />
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
@@ -326,12 +388,35 @@ export default function ProductsPage() {
}
const columns: ColumnsType<Row> = useMemo(() => [
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '品名', dataIndex: 'name', width: 200, ellipsis: true },
{
title: '累计销售',
dataIndex: 'soldBottles',
width: 110,
render: (v: number | undefined) => `累计 ${v ?? 0}`,
},
{
title: '售价',
key: 'priceRange',
width: 120,
render: (_, row) => {
const min = row.priceMin ?? row.price;
const max = row.priceMax ?? row.price;
if (min === max) return `¥${min}`;
return `¥${min} ~ ¥${max}`;
},
},
{
title: '规格数',
dataIndex: 'skuCount',
width: 80,
render: (v: number | undefined, row) => {
const real = v ?? 0;
if (real > 0) return real;
return row.skus?.some((s) => s.virtual) ? 1 : 0;
},
},
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) },
@@ -342,23 +427,7 @@ export default function ProductsPage() {
render: (v: boolean) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
{
title: '履约',
key: 'fulfillment',
width: 160,
render: (_, row) => (
<Space size={[0, 4]} wrap>
{row.allowOnlinePurchase !== false ? <Tag color="blue">线</Tag> : null}
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
<Tag color="cyan"></Tag>
) : null}
{row.allowOnSitePickup ? <Tag color="green"></Tag> : null}
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag></Tag> : null}
</Space>
),
},
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 120,
render: (_, row) => (
@@ -371,7 +440,7 @@ export default function ProductsPage() {
}}></Button>
<Popconfirm
title="确认删除该商品?"
description={`将永久删除「${row.name}${row.skuCode},此操作不可恢复。`}
description={`将永久删除「${row.name}」,此操作不可恢复。`}
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
@@ -384,6 +453,46 @@ export default function ProductsPage() {
},
], [detail, editForm]);
const skuColumns: ColumnsType<SkuListRow> = useMemo(() => [
{
title: '规格',
dataIndex: 'specText',
width: 140,
ellipsis: true,
render: (v: string, row) => (
<Space size={4}>
<span>{v || '默认'}</span>
{row.isDefault ? <Tag color="blue"></Tag> : null}
{row.virtual ? <Tag>SKU</Tag> : null}
</Space>
),
},
{ title: 'SKU', dataIndex: 'skuCode', width: 110 },
{ title: '69码', dataIndex: 'barcode69', width: 160, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` },
{
title: '累计销售',
dataIndex: 'soldBottles',
width: 100,
render: (v: number | undefined) => `${v ?? 0}`,
},
{
title: '履约',
key: 'fulfillment',
width: 160,
render: (_, row) => <FulfillmentTags {...row} />,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
),
},
], []);
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
@@ -391,7 +500,9 @@ export default function ProductsPage() {
<Button type="primary" onClick={() => setCreateOpen(true)}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
@@ -400,17 +511,46 @@ export default function ProductsPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1080 }}
expandable={{
rowExpandable: () => true,
expandedRowRender: (row) => (
<div style={{ margin: '-8px -8px -8px 24px', padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
<Table
size="small"
rowKey="id"
pagination={false}
columns={skuColumns}
dataSource={row.skus ?? []}
scroll={{ x: 980 }}
style={{ background: 'transparent' }}
/>
</div>
),
}}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }}
/>
<Drawer title="编辑商品" width={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
const multiSku = Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1;
if (!multiSku && !v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
if (multiSku) {
delete (payload as { allowOnlinePurchase?: boolean }).allowOnlinePurchase;
delete (payload as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery;
delete (payload as { allowOnSitePickup?: boolean }).allowOnSitePickup;
}
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
@@ -420,13 +560,24 @@ export default function ProductsPage() {
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="默认 SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
{
key: 'base',
label: '基础信息',
children: (
<BaseInfoFields
mode="edit"
form={editForm}
hideFulfillment={Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1}
/>
),
},
{
key: 'detail',
label: '详情页',
@@ -437,6 +588,23 @@ export default function ProductsPage() {
/>
),
},
{
key: 'specs',
label: '规格与 SKU',
children: (
<ProductSpecsEditor
productId={String(detail.id)}
initialAttrs={(detail.specAttrs as never) ?? []}
initialSkus={(detail.skus as never) ?? []}
onSaved={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
void reload();
}}
/>
),
},
]} />
</Form>
</>
+23 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
@@ -26,8 +27,14 @@ function maskPhone(phone: string | null | undefined) {
}
export default function RedeemRecordsPage() {
const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
@@ -43,6 +50,11 @@ export default function RedeemRecordsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
}, [form, initialRedeemNo]);
async function openDetail(id: string) {
setDetailLoading(true);
@@ -54,6 +66,15 @@ export default function RedeemRecordsPage() {
}
}
useEffect(() => {
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.redeemNo) === initialRedeemNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialRedeemNo, loading]);
const columns: ColumnsType<Row> = [
{
title: '核销号',
+79 -6
View File
@@ -44,6 +44,11 @@ type Row = {
settlementRate?: number | null;
payoutCount?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string | null };
bankAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
};
type StoreOption = { id: string; name: string; phone: string };
@@ -55,6 +60,11 @@ const STATUS_COLORS: Record<string, string> = {
REJECTED: 'red',
};
function settlementStatusColor(kind: StoreSettlementKind, status: string) {
if (status === 'PAID') return kind === 'WITHDRAW' ? 'cyan' : 'green';
return STATUS_COLORS[status] || 'default';
}
const KIND_COLORS: Record<StoreSettlementKind, string> = {
T1_BILL: 'blue',
WITHDRAW: 'purple',
@@ -64,10 +74,13 @@ export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || '';
const initialStatus =
searchParams.get('status')?.trim() ||
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
status: initialStatus,
storeId: initialStoreId,
});
const [stores, setStores] = useState<StoreOption[]>([]);
@@ -115,14 +128,30 @@ export default function StoreBillsPage() {
}, []);
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
content: (
<div>
<div>
{ids.length} T+1
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
setBatchLoading(true);
try {
@@ -244,7 +273,9 @@ export default function StoreBillsPage() {
dataIndex: 'kind',
width: 100,
render: (k: StoreSettlementKind) => (
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
<Tag color={KIND_COLORS[k]} style={{ minWidth: 72, textAlign: 'center' }}>
{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}
</Tag>
),
},
{
@@ -268,6 +299,20 @@ export default function StoreBillsPage() {
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
},
{
title: '笔数',
width: 80,
@@ -285,7 +330,9 @@ export default function StoreBillsPage() {
dataIndex: 'status',
width: 100,
render: (s: string, row) => (
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
<Tag color={settlementStatusColor(row.kind, s)}>
{storeSettlementStatusLabel(row.kind, s)}
</Tag>
),
},
{
@@ -336,7 +383,8 @@ export default function StoreBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+1 沿
T+1 沿
= T+1 =
</Typography.Text>
{overdueSummary && overdueSummary.overdueCount > 0 ? (
<Typography.Text type="danger">
@@ -500,7 +548,32 @@ export default function StoreBillsPage() {
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{(detail.storeAccount as {
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
} | null) ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
@@ -24,7 +24,10 @@ import type {
StorePackageChangeRequestDto,
StorePackageChangeStatus,
} from '@dukang/shared-types';
import { STORE_INFO_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
import {
STORE_INFO_CHANGE_STATUS_LABELS,
STORE_INFO_CHANGEABLE_FIELD_LABELS,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
@@ -36,23 +39,6 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
REJECTED: '已驳回',
};
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
name: '门店名称',
contactPhone: '联系电话',
address: '详细地址',
intro: '门店简介',
benefitUsageRule: '权益券使用规则',
latitude: '纬度',
longitude: '经度',
openTime: '营业开始',
closeTime: '营业结束',
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
coverUrl: '门头照',
envPhotoUrls: '环境照片',
};
function fmtFieldValue(field: string, v: unknown): string {
if (v == null || String(v).trim() === '') return '(空)';
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
@@ -69,10 +55,12 @@ function InfoChangeImageDiff({
field,
live,
proposed,
imageSize = 72,
}: {
field: string;
live: unknown;
proposed: unknown;
imageSize?: number;
}) {
const liveUrls =
field === 'coverUrl'
@@ -97,7 +85,7 @@ function InfoChangeImageDiff({
<Image.PreviewGroup>
<Space wrap size={8}>
{liveUrls.map((url) => (
<Image key={`live-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -113,7 +101,7 @@ function InfoChangeImageDiff({
<Image.PreviewGroup>
<Space wrap size={8}>
{proposedUrls.map((url) => (
<Image key={`new-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={`new-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -142,6 +130,7 @@ function InfoChangeAuditPanel({
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
const [infoFullscreen, setInfoFullscreen] = useState(false);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -222,7 +211,7 @@ function InfoChangeAuditPanel({
render: (_, row) =>
row.changedFields?.length
? row.changedFields.map((f) => (
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
))
: '—',
},
@@ -300,25 +289,33 @@ function InfoChangeAuditPanel({
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={680}
width={infoFullscreen ? '100%' : 680}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onClose={() => {
setDetailOpen(false);
setInfoFullscreen(false);
}}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null
<Space>
<Button onClick={() => setInfoFullscreen((v) => !v)}>
{infoFullscreen ? '退出全屏' : '全屏查看'}
</Button>
{detail?.status === 'PENDING' ? (
<>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</>
) : null}
</Space>
}
>
{detailLoading ? (
@@ -339,10 +336,15 @@ function InfoChangeAuditPanel({
{detail.diffs.map((d) => (
<Descriptions.Item
key={d.field}
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
>
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
<InfoChangeImageDiff
field={d.field}
live={d.live}
proposed={d.proposed}
imageSize={infoFullscreen ? 160 : 72}
/>
) : (
<span>
<Typography.Text delete type="secondary">
@@ -402,6 +404,7 @@ export default function StorePackageAuditsPage() {
const [detailOpen, setDetailOpen] = useState(false);
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
const [packageFullscreen, setPackageFullscreen] = useState(false);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -568,14 +571,23 @@ export default function StorePackageAuditsPage() {
<Drawer
title={drawerTitle}
width={880}
width={packageFullscreen ? '100%' : 880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onClose={() => {
setDetailOpen(false);
setPackageFullscreen(false);
}}
destroyOnClose
extra={
<Button onClick={() => setPackageFullscreen((v) => !v)}>
{packageFullscreen ? '退出全屏' : '全屏查看'}
</Button>
}
>
{activeRequestId ? (
<StorePackageAuditPanel
requestId={activeRequestId}
fullscreen={packageFullscreen}
onAudited={() => {
setDetailOpen(false);
void reload(page, status);
+23
View File
@@ -246,6 +246,8 @@ export default function StoresPage() {
const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId') ?? '';
const initialPartnerId = searchParams.get('partnerId') ?? '';
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
const initialStoreId = searchParams.get('storeId') ?? '';
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
@@ -253,6 +255,7 @@ export default function StoresPage() {
const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
@@ -294,6 +297,7 @@ export default function StoresPage() {
const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
const deepLinkStoreOpenedRef = useRef(false);
useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -304,6 +308,12 @@ export default function StoresPage() {
.catch(() => {});
}, []);
useEffect(() => {
if (initialAuditStatus) {
form.setFieldsValue({ auditStatus: initialAuditStatus });
}
}, [form, initialAuditStatus]);
useEffect(() => {
const cityId = searchParams.get('cityId') ?? '';
const partnerId = searchParams.get('partnerId') ?? '';
@@ -454,6 +464,19 @@ export default function StoresPage() {
setDrawerOpen(true);
}
useEffect(() => {
if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return;
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
if (row) {
deepLinkStoreOpenedRef.current = true;
void openStoreDetail(row);
} else if (data && (data.items?.length ?? 0) >= 0) {
// 列表无该店时仍尝试直拉详情
deepLinkStoreOpenedRef.current = true;
void openStoreDetail({ id: initialStoreId } as StoreRow);
}
}, [data, initialStoreId, loading]);
async function saveStoreDetail() {
if (!detail) return;
setSaving(true);
@@ -218,8 +218,13 @@ export default function SupportTicketsPage() {
setReviewOpen(false);
const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500);
const ticketUrls = (detail!.attachmentUrls ?? []).filter(Boolean);
tasksForm.setFieldsValue({
tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }],
tasks: [{
content: summary || detail!.title,
type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType),
attachmentUrls: ticketUrls,
}],
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
});
@@ -364,7 +369,11 @@ export default function SupportTicketsPage() {
body: JSON.stringify({
decision: 'APPROVE',
note: note?.trim() || undefined,
tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })),
tasks: values.tasks.map((t) => ({
content: t.content.trim(),
type: t.type,
attachmentUrls: t.attachmentUrls?.length ? t.attachmentUrls : detail!.attachmentUrls,
})),
dispatchToWecom: !!values.dispatchToWecom,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}),
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Avatar,
Button,
@@ -13,6 +13,7 @@ import {
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
message,
@@ -21,8 +22,13 @@ import type { ColumnsType } from 'antd/es/table';
import {
WECOM_PUSH_CONDITION_GROUPS,
WECOM_PUSH_CONDITION_LABELS,
WECOM_TEMPLATE_EVENT_KEYS,
WECOM_TEMPLATE_EVENT_LABELS,
WECOM_TEMPLATE_PLACEHOLDERS,
type WecomMessagePushDto,
type WecomPushCondition,
type WecomPushTemplateDto,
type WecomTemplateEventKey,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
@@ -78,7 +84,7 @@ function WecomPushConditionPicker({
);
}
export default function WecomMessagePushesPage() {
function PushRoutesTab() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -257,11 +263,9 @@ export default function WecomMessagePushesPage() {
];
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<>
<Typography.Paragraph type="secondary">
Webhook
.env Webhook URL
Webhook / .env Webhook URL
</Typography.Paragraph>
<Form
@@ -332,10 +336,10 @@ export default function WecomMessagePushesPage() {
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:运营告警" />
<Input placeholder="如:业务待办通知群" />
</Form.Item>
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
<OssUpload />
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item
name="webhookUrl"
@@ -400,6 +404,208 @@ export default function WecomMessagePushesPage() {
</Descriptions>
) : null}
</Modal>
</>
);
}
function TemplatesTab() {
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
const [loading, setLoading] = useState(false);
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [preview, setPreview] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
setTemplates(list);
setEventKey((prev) => {
const current = list.find((t) => t.eventKey === prev) ?? list[0];
if (current) {
form.setFieldsValue({
title: current.title,
body: current.body,
handleLabel: current.handleLabel,
});
return current.eventKey;
}
return prev;
});
} catch (e) {
message.error(e instanceof Error ? e.message : '加载模板失败');
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
void load();
}, [load]);
function selectEvent(key: WecomTemplateEventKey) {
setEventKey(key);
const row = templates.find((t) => t.eventKey === key);
if (row) {
form.setFieldsValue({
title: row.title,
body: row.body,
handleLabel: row.handleLabel,
});
setPreview('');
}
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}`,
{
method: 'PUT',
body: JSON.stringify(values),
},
);
message.success('模板已保存');
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function reset() {
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}/reset`,
{ method: 'POST', body: '{}' },
);
message.success('已恢复默认文案');
form.setFieldsValue({
title: updated.title,
body: updated.body,
handleLabel: updated.handleLabel,
});
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
setPreview('');
} catch (e) {
message.error(e instanceof Error ? e.message : '恢复失败');
} finally {
setSaving(false);
}
}
async function testSend() {
setTesting(true);
try {
const res = await request<{ ok: boolean; message: string; preview: string }>(
`/admin/wecom-push-templates/${eventKey}/test`,
{ method: 'POST', body: '{}' },
);
setPreview(res.preview || '');
message.success(res.message || '已发送');
} catch (e) {
message.error(e instanceof Error ? e.message : '测试失败');
} finally {
setTesting(false);
}
}
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
return (
<>
<Typography.Paragraph type="secondary">
使 {'{{orderNo}}'} {'{{handleUrl}}'}
Webhook
</Typography.Paragraph>
<Space align="start" style={{ width: '100%' }} size={24} wrap>
<div style={{ minWidth: 200 }}>
<Typography.Text strong></Typography.Text>
<div style={{ marginTop: 8 }}>
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
<div key={k} style={{ marginBottom: 4 }}>
<Button
type={k === eventKey ? 'primary' : 'text'}
size="small"
onClick={() => selectEvent(k)}
block
style={{ textAlign: 'left' }}
>
{WECOM_TEMPLATE_EVENT_LABELS[k]}
</Button>
</div>
))}
</div>
</div>
<div style={{ flex: 1, minWidth: 360 }}>
<Form form={form} layout="vertical" disabled={loading}>
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="body"
label="正文(企微 markdown"
rules={[{ required: true }]}
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
>
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
<Input placeholder="去处理" />
</Form.Item>
<Space wrap>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
<Button loading={saving}></Button>
</Popconfirm>
<Button loading={testing} onClick={() => void testSend()}>
</Button>
</Space>
</Form>
{preview ? (
<div style={{ marginTop: 16 }}>
<Typography.Text strong></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
whiteSpace: 'pre-wrap',
borderRadius: 8,
}}
>
{preview}
</pre>
</div>
) : null}
</div>
</Space>
</>
);
}
export default function WecomMessagePushesPage() {
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Tabs
items={[
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
]}
/>
</div>
);
}
+49 -2
View File
@@ -37,6 +37,13 @@ type Row = {
wineryAmount: number;
status: string;
paidAt?: string | null;
paymentRef?: string | null;
wineryBank?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
};
type BillItem = {
@@ -127,14 +134,30 @@ export default function WineryBillsPage() {
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
await request('/admin/winery-bills/batch-confirm', {
method: 'POST',
@@ -399,7 +422,31 @@ export default function WineryBillsPage() {
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{detail.wineryBank ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{detail.wineryBank.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户银行">
{detail.wineryBank.bankName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户支行">
{detail.wineryBank.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{detail.wineryBank.bankAccountNo || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
+63 -5
View File
@@ -58,6 +58,7 @@ export default function ProxyOrderPage() {
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
const [productId, setProductId] = useState(draft0.productId);
const [skuId, setSkuId] = useState('');
const [quantity, setQuantity] = useState(draft0.quantity);
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
@@ -156,9 +157,37 @@ export default function ProxyOrderPage() {
]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const selectedSku =
skuOptions.find((s) => s.id === skuId) ||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
skuOptions[0];
const allowOnline = selectedSku
? selectedSku.allowOnlinePurchase !== false
: selectedProduct
? selectedProduct.allowOnlinePurchase !== false
: true;
const allowOnSite = selectedSku
? !!selectedSku.allowOnSitePickup
: !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedSku
? selectedSku.allowCrossCityDelivery !== false
: selectedProduct
? selectedProduct.allowCrossCityDelivery !== false
: true;
useEffect(() => {
if (!selectedProduct) return;
const def =
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
skuOptions.find((s) => s.isDefault) ||
skuOptions[0];
if (def && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
setSkuId(def.id);
if (def.saleUnit === 'BOX') setQuantity(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, selectedProduct?.id, options]);
useEffect(() => {
if (!selectedProduct) return;
@@ -184,6 +213,7 @@ export default function ProxyOrderPage() {
productId,
quantity,
deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
@@ -200,7 +230,7 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
}, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
@@ -359,6 +389,7 @@ export default function ProxyOrderPage() {
productId,
quantity,
promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
};
setSubmitting(true);
@@ -524,8 +555,35 @@ export default function ProxyOrderPage() {
</button>
</section>
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<select
className="partner-input"
value={skuId}
onChange={(e) => {
const id = e.target.value;
setSkuId(id);
const sku = skuOptions.find((s) => s.id === id);
if (sku?.saleUnit === 'BOX') setQuantity(1);
}}
>
{skuOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.specText || '默认'} · ¥{fmtMoney(s.price)} ·{' '}
{s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}
</option>
))}
</select>
</div>
</section>
) : null}
<section className="partner-form-section">
<label className="partner-form-label"></label>
<label className="partner-form-label">
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
</label>
<div className="partner-input-wrap">
<input
className="partner-input"
+1 -1
View File
@@ -463,7 +463,7 @@ export default function HomePage() {
<div className="shop-home-stat">
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value">
+1 -3
View File
@@ -196,9 +196,7 @@ export default function RecordsPage() {
</div>
<p className="shop-record-time">
:{' '}
{new Date(String(r.createdAt))
.toLocaleString('zh-CN', { hour12: false })
.slice(0, 16)}
{new Date(String(r.createdAt)).toLocaleString('zh-CN', { hour12: false })}
</p>
<p className="shop-record-channel">
<span
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/mini-user",
"version": "3.4.15",
"version": "3.5.4",
"private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,19 +1,20 @@
import { Text, View } from '@tarojs/components';
import BenefitFigure from './BenefitFigure';
type CouponBadgeProps = {
amount: number | string;
label?: string;
};
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge */
/** 首页商品角标:纯文案「享{amount}好客权益」(无门店图标 */
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
const n = Number(amount);
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
return (
<View className="coupon-badge">
<Text></Text>
<BenefitFigure value={`${display} ${label}`} size="sm" />
<Text>
{display}
{label}
</Text>
</View>
);
}
+5
View File
@@ -1,5 +1,6 @@
export type CheckoutContext = {
productId?: string;
skuId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
@@ -9,6 +10,7 @@ export type CheckoutContext = {
export function buildQuery(ctx: CheckoutContext): string {
const parts: string[] = [];
if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`);
if (ctx.skuId) parts.push(`skuId=${encodeURIComponent(ctx.skuId)}`);
if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`);
if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
if (ctx.cross) parts.push('cross=1');
@@ -36,12 +38,14 @@ export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext
export function buildPayUrl(params: {
orderId: string;
productId?: string;
skuId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
}): string {
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`);
if (params.skuId) parts.push(`skuId=${encodeURIComponent(params.skuId)}`);
if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`);
if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
if (params.cross) parts.push('cross=1');
@@ -51,6 +55,7 @@ export function buildPayUrl(params: {
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
return {
productId: params.productId,
skuId: params.skuId,
qty: params.qty,
addressId: params.addressId,
cross: params.cross === '1',
+1 -1
View File
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
export const APP_VERSION = '3.4.15';
export const APP_VERSION = '3.5.4';
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
@@ -7,9 +7,9 @@ import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { usePageView } from '../../lib/usePageView';
import {
INVOICE_KIND_LABELS,
INVOICE_CATEGORY_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceCategory,
type UserInvoiceTitleDto,
} from '@dukang/shared-types';
@@ -42,7 +42,8 @@ export default function InvoiceApplyPage() {
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
const [selectedId, setSelectedId] = useState('');
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
const [invoiceCategory, setInvoiceCategory] = useState<InvoiceCategory>('LIQUOR');
const [remark, setRemark] = useState('');
const [emailOverride, setEmailOverride] = useState('');
const [phoneOverride, setPhoneOverride] = useState('');
const [loading, setLoading] = useState(true);
@@ -103,19 +104,17 @@ export default function InvoiceApplyPage() {
toast('请填写联系电话');
return;
}
if (invoiceKind === 'SPECIAL' && selected.titleType !== 'ENTERPRISE') {
toast('专用发票仅支持企业抬头');
return;
}
setSubmitting(true);
try {
await request(`/trade/orders/${orderId}/invoices`, {
method: 'POST',
data: {
titleId: selectedId,
invoiceKind,
invoiceKind: 'NORMAL',
invoiceCategory,
email,
phone,
remark: remark.trim() || undefined,
},
});
toast('发票申请已提交', 'success');
@@ -192,20 +191,26 @@ export default function InvoiceApplyPage() {
) : null}
{canApply ? (
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<View className="invoice-title-type-row">
{(['NORMAL', 'SPECIAL'] as InvoiceKind[]).map((k) => (
<Text
key={k}
className={`invoice-title-type-chip${invoiceKind === k ? ' active' : ''}`}
onClick={() => setInvoiceKind(k)}
>
{INVOICE_KIND_LABELS[k]}
</Text>
))}
<>
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<Text className="invoice-kind-static"></Text>
</View>
</View>
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<View className="invoice-title-type-row">
{(['LIQUOR', 'CATERING'] as InvoiceCategory[]).map((k) => (
<Text
key={k}
className={`invoice-title-type-chip${invoiceCategory === k ? ' active' : ''}`}
onClick={() => setInvoiceCategory(k)}
>
{INVOICE_CATEGORY_LABELS[k]}
</Text>
))}
</View>
</View>
</>
) : null}
{canApply ? (
@@ -258,7 +263,11 @@ export default function InvoiceApplyPage() {
{canApply && selected && !selected.email ? (
<View className="invoice-title-field" style={{ marginTop: 16 }}>
<Text className="invoice-title-label"></Text>
<Text className="invoice-title-label">
<Text className="invoice-title-star">*</Text>
<Text className="invoice-title-label-hint"></Text>
</Text>
<Input
className="invoice-title-input"
placeholder="电子发票将发送至此邮箱"
@@ -279,6 +288,19 @@ export default function InvoiceApplyPage() {
/>
</View>
) : null}
{canApply ? (
<View className="invoice-title-field" style={{ marginTop: 16 }}>
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
maxlength={200}
placeholder="选填,将显示在发票申请备注中"
value={remark}
onInput={(e) => setRemark(e.detail.value)}
/>
</View>
) : null}
</View>
{canApply && titles.length > 0 ? (
@@ -26,6 +26,24 @@ const ACTION_ICONS = {
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
function FieldLabel({
children,
required,
hint,
}: {
children: string;
required?: boolean;
hint?: string;
}) {
return (
<Text className="invoice-title-label">
{required ? <Text className="invoice-title-star">*</Text> : null}
{children}
{hint ? <Text className="invoice-title-label-hint">{hint}</Text> : null}
</Text>
);
}
function TitleActionIcon({
kind,
label,
@@ -120,6 +138,15 @@ export default function InvoiceTitlesPage() {
toast('企业抬头须填写税号');
return;
}
const email = draft.email?.trim() || '';
if (!email) {
toast('请填写接收邮箱(必填)');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
toast('邮箱格式不正确');
return;
}
setSaving(true);
try {
const payload: UpsertInvoiceTitleRequest = {
@@ -152,6 +179,10 @@ export default function InvoiceTitlesPage() {
}
async function setDefault(t: UserInvoiceTitleDto) {
if (!t.email?.trim()) {
toast('请先补全接收邮箱后再设为默认');
return;
}
try {
await request(`/trade/invoice-titles/${t.id}`, {
method: 'PUT',
@@ -241,7 +272,7 @@ export default function InvoiceTitlesPage() {
</Text>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<View className="invoice-title-type-row">
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
<Text
@@ -258,7 +289,7 @@ export default function InvoiceTitlesPage() {
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<Input
className="invoice-title-input"
maxlength={128}
@@ -270,7 +301,7 @@ export default function InvoiceTitlesPage() {
{draft.titleType === 'ENTERPRISE' ? (
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<Input
className="invoice-title-input"
maxlength={32}
@@ -282,11 +313,13 @@ export default function InvoiceTitlesPage() {
) : null}
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required hint="必填,电子发票将发送至此">
</FieldLabel>
<Input
className="invoice-title-input"
maxlength={128}
placeholder="电子发票将发送至此邮箱"
placeholder="请填写邮箱(必填)"
value={draft.email || ''}
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
/>
@@ -311,7 +344,7 @@ export default function InvoiceTitlesPage() {
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
placeholder="选填"
value={draft.addressPhone || ''}
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
/>
@@ -321,7 +354,7 @@ export default function InvoiceTitlesPage() {
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
placeholder="选填"
value={draft.bankAccount || ''}
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
/>
@@ -32,12 +32,14 @@ type OrderPreview = {
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
saleUnit?: 'BOTTLE' | 'BOX';
};
export default function OrderConfirmPickupPage() {
const router = useRouter();
const productId = router.params.productId ?? '';
const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2)));
const skuId = router.params.skuId ?? '';
const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 2)));
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
@@ -50,7 +52,7 @@ export default function OrderConfirmPickupPage() {
setPreviewLoading(true);
request<OrderPreview>('/trade/orders/preview', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
})
.then((data) => {
if (!cancelled) {
@@ -70,15 +72,16 @@ export default function OrderConfirmPickupPage() {
return () => {
cancelled = true;
};
}, [productId, quantity]);
}, [productId, skuId, quantity]);
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
const minQty = preview?.minQty ?? 2;
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < minQty) {
const tip = `现场提货至少购买 ${minQty}`;
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
@@ -91,12 +94,13 @@ export default function OrderConfirmPickupPage() {
async function doSubmit() {
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
skuId: skuId || undefined,
qty: String(quantity),
}),
});
@@ -54,6 +54,9 @@ type OrderPreview = {
minQty?: number;
allowCrossCityDelivery?: boolean;
allowOnlinePurchase?: boolean;
skuId?: string;
saleUnit?: 'BOTTLE' | 'BOX';
bottlesPerUnit?: number;
};
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
@@ -66,6 +69,7 @@ export default function OrderConfirmPage() {
const router = useRouter();
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const skuId = checkoutCtx.skuId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
const [addresses, setAddresses] = useState<Address[]>([]);
@@ -96,11 +100,12 @@ export default function OrderConfirmPage() {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
const body: { productId: string; quantity: number; addressId?: string } = {
const body: { productId: string; quantity: number; addressId?: string; skuId?: string } = {
productId,
quantity,
};
if (addressId) body.addressId = addressId;
if (skuId) body.skuId = skuId;
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
.then((data) => {
@@ -139,7 +144,7 @@ export default function OrderConfirmPage() {
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
}, [productId, skuId, quantity, addressId]);
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
@@ -157,6 +162,7 @@ export default function OrderConfirmPage() {
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
const crossBlocked = isCross && !allowCross;
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
const minQty =
preview?.minQty ??
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
@@ -171,8 +177,8 @@ export default function OrderConfirmPage() {
function updateQuantity(next: number) {
if (next < minQty) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
? `跨城配送至少购买 ${minQty}${unitLabel}`
: `同城配送至少购买 ${minQty}${unitLabel}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
@@ -195,6 +201,7 @@ export default function OrderConfirmPage() {
productId,
quantity,
addressId,
...(skuId ? { skuId } : {}),
...(clientLocation ? { clientLocation } : {}),
},
});
@@ -202,6 +209,7 @@ export default function OrderConfirmPage() {
url: buildPayUrl({
orderId: order.id,
productId,
skuId: skuId || undefined,
qty: String(quantity),
addressId,
cross: forceCross,
@@ -223,15 +231,15 @@ export default function OrderConfirmPage() {
}
if (!quantityOk) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
? `跨城配送至少购买 ${minQty}${unitLabel}`
: `同城配送至少购买 ${minQty}${unitLabel}`;
setMsg(tip);
toast(tip);
}
return;
}
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${skuId ? `&skuId=${skuId}` : ''}${forceCross ? '&cross=1' : ''}`;
if (!phonePromptSkipped.current) {
try {
@@ -371,8 +379,8 @@ export default function OrderConfirmPage() {
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty},请调整数量`}
? `跨城配送至少购买 ${minQty}${unitLabel},请调整数量`
: `同城配送至少购买 ${minQty}${unitLabel},请调整数量`}
</Text>
) : null}
</View>
+3 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, Image } from '@tarojs/components';
import '../../styles/order.css';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
@@ -19,13 +19,13 @@ import { applyWechatLoginResult } from '../../lib/wechat-auth';
import { isWechatEnv } from '../../lib/weixin';
import { goLogin } from '../../lib/auth-nav';
import { request, toast } from '../../lib/api';
import payLogo from '../../assets/logo2.png';
export default function PayPage() {
const router = useRouter();
const orderId = router.params.orderId ?? '';
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState('');
const [orderNo, setOrderNo] = useState('');
@@ -39,7 +39,6 @@ export default function PayPage() {
const refreshPayReadiness = useCallback(async () => {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
return profile;
} catch {
@@ -174,7 +173,7 @@ export default function PayPage() {
<View className="sub-page-body">
<View className="pay-status">
<View className="pay-status-icon">
<Text>¥</Text>
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" />
</View>
<Text className="pay-status-title">
{needsWechatAuth ? '需完成微信授权' : '待支付'}
@@ -204,12 +203,6 @@ export default function PayPage() {
<Text className="order-row-label"></Text>
<Text className="order-row-value"></Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">
{mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'}
</Text>
</View>
</View>
{msg ? (
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
+146 -13
View File
@@ -8,7 +8,7 @@ import Taro, {
useShareAppMessage,
useShareTimeline,
} from '@tarojs/taro';
import type { ProductDetailContentDto } from '@dukang/shared-types';
import type { ProductDetailContentDto, ProductSkuDto, ProductSpecAttrDto } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
@@ -47,8 +47,39 @@ type Product = ProductImageSource & {
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
specEnabled?: boolean;
specAttrs?: ProductSpecAttrDto[];
skus?: ProductSkuDto[];
defaultSkuId?: string;
saleUnit?: 'BOTTLE' | 'BOX';
};
function findSku(
skus: ProductSkuDto[],
selected: Record<string, string>,
attrs: ProductSpecAttrDto[],
): ProductSkuDto | undefined {
const valueIds = attrs.map((a) => selected[a.id]).filter(Boolean);
if (valueIds.length !== attrs.length) return undefined;
const key = [...valueIds].sort().join('_');
return skus.find((s) => [...s.specValueIds].sort().join('_') === key);
}
function isValueAvailable(
skus: ProductSkuDto[],
attrs: ProductSpecAttrDto[],
selected: Record<string, string>,
attrId: string,
valueId: string,
): boolean {
const trial = { ...selected, [attrId]: valueId };
const partialIds = attrs.map((a) => trial[a.id]).filter(Boolean);
return skus.some((s) => {
if (s.status !== 'ON_SALE') return false;
return partialIds.every((id) => s.specValueIds.includes(id));
});
}
export default function ProductDetailPage() {
const router = useRouter();
const productId = router.params.id ?? '';
@@ -58,6 +89,7 @@ export default function ProductDetailPage() {
);
const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);
const [selected, setSelected] = useState<Record<string, string>>({});
usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100);
@@ -72,7 +104,25 @@ export default function ProductDetailPage() {
toast('商品不存在或暂未开放');
return;
}
setProduct(normalizeFulfillmentFlags(p));
const normalized = normalizeFulfillmentFlags(p);
setProduct(normalized);
const attrs = p.specAttrs ?? [];
const skus = p.skus ?? [];
const def =
skus.find((s) => s.id === p.defaultSkuId) ||
skus.find((s) => s.isDefault && s.status === 'ON_SALE') ||
skus.find((s) => s.status === 'ON_SALE') ||
skus[0];
if (def && attrs.length) {
const next: Record<string, string> = {};
for (const attr of attrs) {
const hit = attr.values.find((v) => def.specValueIds.includes(v.id));
if (hit) next[attr.id] = hit.id;
}
setSelected(next);
} else {
setSelected({});
}
})
.catch((e) => {
setProduct(null);
@@ -84,20 +134,53 @@ export default function ProductDetailPage() {
loadProduct();
}, [loadProduct]);
// 登录后返回详情须带 token 重拉,否则白名单商品会一直空白
useDidShow(() => {
loadProduct();
});
const attrs = product?.specAttrs ?? [];
const skus = product?.skus ?? [];
const specEnabled = !!(product?.specEnabled && attrs.length > 0);
const activeSku = useMemo(() => {
if (!product) return undefined;
if (!specEnabled) {
return (
skus.find((s) => s.id === product.defaultSkuId) ||
skus.find((s) => s.isDefault) ||
skus.find((s) => s.status === 'ON_SALE') ||
skus[0]
);
}
return findSku(skus, selected, attrs);
}, [product, specEnabled, skus, selected, attrs]);
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
const displayBenefit = activeSku
? Number(activeSku.benefitAmount)
: Number(product?.benefitDisplay ?? product?.benefitAmount ?? product?.price ?? 0);
const fulfillment = activeSku
? {
allowOnlinePurchase: activeSku.allowOnlinePurchase,
allowCrossCityDelivery: activeSku.allowCrossCityDelivery,
allowOnSitePickup: activeSku.allowOnSitePickup,
}
: product;
const carouselImages = (() => {
const base = getProductCarouselImages(product);
const skuImg = activeSku?.imageUrl?.trim();
if (!skuImg) return base;
return [skuImg, ...base.filter((url) => url !== skuImg)];
})();
const sharePayload = useMemo(
() =>
buildSceneSharePayload('productDetail', {
path: `/pages/product-detail/index?id=${productId}`,
dynamicTitle: product?.name,
dynamicDesc: product?.subtitle,
dynamicImageUrl: product ? getProductMainImage(product) : undefined,
dynamicImageUrl: (activeSku?.imageUrl?.trim() || (product ? getProductMainImage(product) : undefined)),
}),
[product, productId],
[product, productId, activeSku],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
@@ -118,9 +201,23 @@ export default function ProductDetailPage() {
Taro.switchTab({ url: '/pages/home/index' });
}
function ensureSkuSelected(): string | null {
if (!specEnabled) return activeSku?.id ?? product?.defaultSkuId ?? null;
if (!activeSku || activeSku.status !== 'ON_SALE') {
toast('请选择完整规格');
return null;
}
return activeSku.id;
}
async function goBuy() {
if (!productId) return;
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`;
const skuId = ensureSkuSelected();
if (specEnabled && !skuId) return;
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
const qs = [`productId=${productId}`, `qty=${qty}`];
if (skuId) qs.push(`skuId=${skuId}`);
const returnPath = `/pages/order-confirm/index?${qs.join('&')}`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
@@ -132,7 +229,12 @@ export default function ProductDetailPage() {
async function goOnSitePickup() {
if (!productId) return;
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
const skuId = ensureSkuSelected();
if (specEnabled && !skuId) return;
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1;
const qs = [`productId=${productId}`, `qty=${qty}`];
if (skuId) qs.push(`skuId=${skuId}`);
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
@@ -151,10 +253,8 @@ export default function ProductDetailPage() {
);
}
const allowOnline = canBuyOnline(product);
const allowOnSite = canPickupOnSite(product);
const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price);
const carouselImages = getProductCarouselImages(product);
const allowOnline = canBuyOnline(fulfillment ?? {});
const allowOnSite = canPickupOnSite(fulfillment ?? {});
const detailImages = getProductDetailImages(product);
const detail = product.detailContent ?? {};
const features = detail.features ?? [];
@@ -177,12 +277,45 @@ export default function ProductDetailPage() {
<View className="product-detail-info">
<View className="product-detail-price">
<Text className="product-detail-price-symbol">¥</Text>
<Text className="product-detail-price-value">{Number(product.price).toFixed(2)}</Text>
<Text className="product-detail-price-value">{displayPrice.toFixed(2)}</Text>
</View>
<Text className="product-detail-name">{product.name}</Text>
{product.subtitle ? (
<Text className="product-detail-subtitle">{product.subtitle}</Text>
) : null}
{activeSku?.specText ? (
<Text className="product-detail-subtitle">{activeSku.specText}</Text>
) : null}
{specEnabled ? (
<View className="product-detail-specs">
{attrs.map((attr) => (
<View key={attr.id} className="product-detail-spec-row">
<Text className="product-detail-spec-label">{attr.name}</Text>
<View className="product-detail-spec-chips">
{attr.values.map((val) => {
const active = selected[attr.id] === val.id;
const available = isValueAvailable(skus, attrs, selected, attr.id, val.id);
return (
<View
key={val.id}
className={`product-detail-spec-chip${active ? ' is-active' : ''}${
available ? '' : ' is-disabled'
}`}
onClick={() => {
if (!available) return;
setSelected((prev) => ({ ...prev, [attr.id]: val.id }));
}}
>
<Text className="product-detail-spec-chip-text">{val.name}</Text>
</View>
);
})}
</View>
</View>
))}
</View>
) : null}
<View className="product-detail-promo">
<View className="product-detail-promo-glow" />
@@ -192,7 +325,7 @@ export default function ProductDetailPage() {
</View>
<View className="product-detail-promo-title">
<Text> · </Text>
<BenefitFigure value={String(benefit)} size="sm" className="product-detail-promo-amount" />
<BenefitFigure value={String(displayBenefit)} size="sm" className="product-detail-promo-amount" />
</View>
</View>
<Text className="product-detail-promo-desc">
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import { View, Text, Image, ScrollView } from '@tarojs/components';
import '../../styles/store-detail.css';
import Taro, {
useDidShow,
@@ -444,17 +444,19 @@ export default function StoreDetailPage() {
</View>
) : null}
{intro ? (
{benefitRule ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<Text className="store-detail-intro">{intro}</Text>
<Text className="store-detail-section-title store-detail-section-title--rule">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{benefitRule ? (
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
<Text className="store-detail-section-title"></Text>
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
<Text className="store-detail-intro">{intro}</Text>
</ScrollView>
</View>
) : null}
+24
View File
@@ -153,6 +153,30 @@
margin-bottom: 6px;
}
.invoice-title-star {
color: #c62828;
margin-right: 2px;
font-weight: 600;
}
.invoice-title-label-hint {
margin-left: 6px;
font-size: 12px;
color: #c62828;
font-weight: 500;
}
.invoice-kind-static {
display: block;
font-size: 15px;
color: var(--color-on-surface, #1a1a1a);
line-height: 44px;
padding: 0 12px;
background: var(--color-surface, #fafafa);
border: 1px solid var(--color-outline, #ddd);
border-radius: 8px;
}
.invoice-title-input {
width: 100%;
height: 44px;
+9 -5
View File
@@ -488,16 +488,20 @@
}
.pay-status-icon {
width: 72px;
height: 72px;
width: 88px;
height: 88px;
border-radius: 50%;
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 36px;
background: #f7f4ee;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
overflow: hidden;
}
.pay-status-brand {
width: 72px;
height: 72px;
}
.pay-status-title {
@@ -130,6 +130,7 @@
.product-detail-promo {
position: relative;
margin-top: 8px;
padding: 16px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
@@ -375,3 +376,53 @@
font-size: 16px;
font-weight: 700;
}
.product-detail-specs {
margin: 16px 0 28px;
display: flex;
flex-direction: column;
gap: 12px;
}
.product-detail-spec-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.product-detail-spec-label {
font-size: 13px;
color: var(--color-on-surface-variant, #666);
}
.product-detail-spec-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.product-detail-spec-chip {
padding: 6px 14px;
border-radius: 8px;
background: #f5f5f5;
border: 1px solid transparent;
}
.product-detail-spec-chip.is-active {
background: rgba(166, 29, 36, 0.08);
border-color: var(--color-heritage-red);
}
.product-detail-spec-chip.is-disabled {
opacity: 0.35;
}
.product-detail-spec-chip-text {
font-size: 13px;
color: #333;
}
.product-detail-spec-chip.is-active .product-detail-spec-chip-text {
color: var(--color-heritage-red);
font-weight: 600;
}
@@ -211,6 +211,17 @@
color: var(--color-on-surface);
}
.store-detail-section-title--rule {
color: var(--color-heritage-red, #a61d24);
}
.store-detail-intro-scroll {
max-height: 168px;
height: 168px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.store-detail-env-grid {
display: flex;
flex-direction: column;
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const crypto = require('crypto');
const path = process.argv[2] || '/opt/dukang/server/dukang-api/.env.production';
function stripQuotes(s) {
const t = s.trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1);
}
return t;
}
function parseEnvValue(text, key) {
const re = new RegExp(`^${key}=(.*)$`, 'm');
const m = text.match(re);
if (!m) return null;
return stripQuotes(m[1]);
}
function tryKey(name, key) {
const literalN = (key.match(/\\n/g) || []).length;
const realN = (key.match(/\n/g) || []).length;
const hasBegin = /BEGIN (RSA )?PRIVATE KEY/.test(key);
try {
crypto.createPrivateKey(key);
console.log(`${name}: OK begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length}`);
return true;
} catch (e) {
console.log(
`${name}: FAIL ${e.message} begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length} head=${JSON.stringify(key.slice(0, 48))}`,
);
return false;
}
}
const text = fs.readFileSync(path, 'utf8');
const raw = parseEnvValue(text, 'WX_MCH_PRIVATE_KEY');
if (!raw) {
console.log('MISSING WX_MCH_PRIVATE_KEY in', path);
process.exit(1);
}
console.log('file=', path);
console.log('MOCK_PAY=', parseEnvValue(text, 'MOCK_PAY'));
console.log('WX_MINI_APP_ID=', parseEnvValue(text, 'WX_MINI_APP_ID'));
console.log('WX_APP_ID=', parseEnvValue(text, 'WX_APP_ID'));
console.log('WX_MCH_ID=', parseEnvValue(text, 'WX_MCH_ID'));
console.log('WX_PAY_NOTIFY_URL=', parseEnvValue(text, 'WX_PAY_NOTIFY_URL'));
const variants = {
as_is: raw,
unescape_n: raw.replace(/\\n/g, '\n'),
unescape_twice: raw.replace(/\\\\n/g, '\\n').replace(/\\n/g, '\n'),
strip_cr_unesc: raw.replace(/\\n/g, '\n').replace(/\r/g, ''),
// dotenv style sometimes leaves surrounding quotes in process.env
quoted_unesc: stripQuotes(raw).replace(/\\n/g, '\n'),
};
let ok = false;
for (const [name, key] of Object.entries(variants)) {
if (tryKey(name, key)) ok = true;
}
// also simulate dotenv load
try {
const dotenv = require('dotenv');
const parsed = dotenv.parse(text);
const fromDotenv = parsed.WX_MCH_PRIVATE_KEY || '';
console.log('dotenv_raw_literalN=', (fromDotenv.match(/\\n/g) || []).length, 'realN=', (fromDotenv.match(/\n/g) || []).length);
tryKey('dotenv_as_is', fromDotenv);
tryKey('dotenv_unescape', fromDotenv.replace(/\\n/g, '\n'));
} catch (e) {
console.log('dotenv skip', e.message);
}
process.exit(ok ? 0 : 2);
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# backup-prod-db.sh — 将线上(production)数据库 mysqldump 落盘到 deploy/backups/
#
# 与 sync-prod-db-to-local.sh 共用 SSH 隧道;仅导出,不覆盖本地库。
#
# 用法:
# bash deploy/backup-prod-db.sh # 交互确认
# bash deploy/backup-prod-db.sh --yes # 跳过确认
# bash deploy/backup-prod-db.sh --dry-run # 只打印计划
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
LOCAL_CONTAINER="dukang-v1-mysql"
TUNNEL_PORT=6019
REMOTE_ENV_FILE="/opt/dukang/server/dukang-api/.env.production"
HOST_ALIAS="host.docker.internal"
ASSUME_YES=0
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
--help|-h) sed -n '3,12p' "$0"; exit 0 ;;
--tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;;
--container=*) LOCAL_CONTAINER="${arg#*=}" ;;
--host-alias=*) HOST_ALIAS="${arg#*=}" ;;
*) echo "未知参数: $arg" >&2; exit 2 ;;
esac
done
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"
echo "==> 读取线上数据库配置 ($TARGET:$REMOTE_ENV_FILE)"
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)"
mkdir -p backups
OUT="backups/prod-${REMOTE_DB}-$(date +%Y%m%d-%H%M%S).sql"
echo
echo "备份计划:"
echo " 源(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
echo " 目标文件: $OUT"
echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run) 已结束,未做任何修改。"
exit 0
fi
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
echo "本地容器 $LOCAL_CONTAINER 未运行。请先执行: cd deploy && docker compose up -d" >&2
exit 1
fi
if [[ $ASSUME_YES -ne 1 ]]; then
read -r -p "确认备份【线上 $REMOTE_DB】到 $OUT ? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; }
fi
CTL="/tmp/backup-prod-db-$$.sock"
echo "==> 建立 SSH 隧道"
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
ready=0
for _ 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 "==> mysqldump 导出中..."
docker exec -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 \
--skip-routines --skip-events --column-statistics=0 --no-tablespaces --set-gtid-purged=OFF \
--databases "$REMOTE_DB" > "$OUT"
RC=$?
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
if [[ $RC -ne 0 ]]; then
echo "备份失败 (mysqldump 退出码 $RC)。" >&2
exit 1
fi
echo "==> 备份完成 ✅ $OUT ($(wc -c < "$OUT") bytes)"
echo " 提示:请在阿里云 RDS 控制台再创建一次手动快照,作为整库回滚保险。"
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
# 为 api.lingshivip.cn 申请 DNS 证书并启用 HTTPS
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SSL_DIR="/etc/nginx/ssl/api.lingshivip.cn"
NGINX_CONF="/etc/nginx/conf.d/dukang-lingshivip.conf"
mkdir -p "$SSL_DIR" /var/www/certbot/.well-known/acme-challenge
echo "==> 1. 发起 DNS 验证(需先在 DNSPod 添加 TXT 记录)"
~/.acme.sh/acme.sh --issue -d api.lingshivip.cn --dns \
--yes-I-know-dns-manual-mode-enough-go-ahead-please \
--server letsencrypt || true
echo ""
echo "请在 DNSPod 添加上述 TXT 记录后,执行:"
echo " $0 --renew"
echo ""
if [[ "${1:-}" != "--renew" ]]; then
exit 0
fi
echo "==> 2. 继续 DNS 验证并签发证书"
~/.acme.sh/acme.sh --renew -d api.lingshivip.cn --yes-I-know-dns-manual-mode-enough-go-ahead-please
echo "==> 3. 安装证书到 nginx 目录"
install -m 644 ~/.acme.sh/api.lingshivip.cn_ecc/fullchain.cer "$SSL_DIR/fullchain.cer"
install -m 600 ~/.acme.sh/api.lingshivip.cn_ecc/api.lingshivip.cn.key "$SSL_DIR/api.lingshivip.cn.key"
echo "==> 4. 更新 nginx 并重载"
cp "$SCRIPT_DIR/nginx-lingshivip.conf" "$NGINX_CONF"
python3 - <<'PY'
from pathlib import Path
p = Path("/etc/nginx/conf.d/dukang-lingshivip.conf")
text = p.read_text()
old = """ location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
# API 独立域名 HTTPS"""
new = """ location / {
return 301 https://$host$request_uri;
}
}
# API 独立域名 HTTPS"""
if old not in text:
raise SystemExit("nginx api http redirect patch failed")
p.write_text(text.replace(old, new, 1))
PY
nginx -t
systemctl reload nginx
echo "==> 5. 健康检查"
curl -sf -o /dev/null -w "api-https:%{http_code}\n" https://api.lingshivip.cn/api/v1/health
echo "==> api.lingshivip.cn HTTPS 已启用"
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# runxian.top 裸域名申请 Let's Encrypt 并启用 HTTPS
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DOMAIN=runxian.top
EMAIL="${CERTBOT_EMAIL:-admin@runxian.top}"
echo "==> 检查 DNS: $DOMAIN"
ip="$(dig +short "$DOMAIN" A @223.5.5.5 | tail -1)"
if [[ -z "$ip" ]]; then
echo "ERROR: $DOMAIN 无 A 记录,请先在 DNS 添加指向本机公网 IP(当前服务器: $(curl -sf ifconfig.me || echo unknown)"
exit 1
fi
echo " $DOMAIN -> $ip"
mkdir -p /var/www/certbot /var/log/nginx/dukang
# 若尚未有证书,先部署仅 HTTP 的配置以便 ACME 校验
if [[ ! -f /etc/letsencrypt/live/runxian.top/fullchain.pem ]]; then
echo "==> 临时 HTTP 配置(用于 ACME..."
cat > /etc/nginx/conf.d/dukang-runxian-apex.conf <<'EOF'
server {
listen 80;
server_name runxian.top;
root /opt/dukang-haoke/public;
index index.html;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
try_files $uri $uri/ =404;
}
}
EOF
nginx -t
systemctl reload nginx
fi
echo "==> 申请证书..."
certbot certonly --webroot -w /var/www/certbot \
--cert-name runxian.top \
-d runxian.top \
--non-interactive --agree-tos -m "$EMAIL"
echo "==> 切换 HTTPS 配置..."
install -m 644 "$SCRIPT_DIR/nginx-runxian-apex.conf" /etc/nginx/conf.d/dukang-runxian-apex.conf
nginx -t
systemctl reload nginx
echo "==> 验证..."
code="$(curl -sf -o /dev/null -w '%{http_code}' "https://$DOMAIN/MP_verify_ayPJ4CQqbUcec3jX.txt" || echo fail)"
echo " https://$DOMAIN/MP_verify_ayPJ4CQqbUcec3jX.txt -> $code"
echo "==> runxian.top HTTPS 已启用"
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# 为杜康 runxian.top 申请 Let's Encrypt 并切换 HTTPS 配置
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DOMAINS=(
user.runxian.top
shop.runxian.top
partner.runxian.top
webadmin.runxian.top
dkapi.runxian.top
)
echo "==> 检查 DNS 解析到本机..."
for d in "${DOMAINS[@]}"; do
ip="$(dig +short "$d" @223.5.5.5 | tail -1)"
if [[ -z "$ip" ]]; then
echo "ERROR: $d 无 A 记录,请先在 DNS 添加指向本机公网 IP"
exit 1
fi
echo " $d -> $ip"
done
mkdir -p /var/www/certbot /var/log/nginx/dukang
echo "==> 申请证书..."
certbot certonly --webroot -w /var/www/certbot \
--cert-name user.runxian.top \
-d user.runxian.top \
-d shop.runxian.top \
-d partner.runxian.top \
-d webadmin.runxian.top \
-d dkapi.runxian.top \
--non-interactive --agree-tos -m admin@runxian.top || {
echo "certbot 失败,请确认 DNS 已生效且 80 端口可从公网访问"
exit 1
}
echo "==> 切换 nginx HTTPS 配置..."
install -m 644 "$SCRIPT_DIR/nginx-runxian-dukang-ssl.conf" /etc/nginx/conf.d/dukang-runxian.conf
nginx -t
systemctl reload nginx
echo "==> 验证..."
for url in \
https://user.runxian.top/user/ \
https://user.runxian.top/shop/ \
https://user.runxian.top/partner/ \
https://shop.runxian.top/ \
https://webadmin.runxian.top/ \
https://dkapi.runxian.top/api/v1/health; do
code="$(curl -sf -o /dev/null -w '%{http_code}' "$url" || echo fail)"
echo " $url -> $code"
done
echo "==> runxian.top 杜康域名 HTTPS 已启用(H5 统一入口 user.runxian.top"
-33
View File
@@ -1,33 +0,0 @@
map $host $dukang_h5_port {
user.ai-mirror.xyz 8091;
shop.ai-mirror.xyz 8092;
partner.ai-mirror.xyz 8093;
}
# HTTP — 证书申请期间先提供 HTTP 服务
server {
listen 80;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:$dukang_h5_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-54
View File
@@ -1,54 +0,0 @@
# 杜康好客 H5 三端 — ai-mirror.xyz
# API 内部端口 8090H5 分别 8091/8092/8093
map $host $dukang_h5_port {
user.ai-mirror.xyz 8091;
shop.ai-mirror.xyz 8092;
partner.ai-mirror.xyz 8093;
}
# HTTP → HTTPS
server {
listen 80;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
access_log /var/log/nginx/dukang/access.log main;
error_log /var/log/nginx/dukang/error.log warn;
ssl_certificate /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.ai-mirror.xyz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_h5_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-105
View File
@@ -1,105 +0,0 @@
# 证书申请期间 — lingshivip.cn HTTPapi HTTPS 证书就绪后换 nginx-lingshivip.conf
map $host $dukang_lingshi_port {
user.lingshivip.cn 8091;
shop.lingshivip.cn 8092;
partner.lingshivip.cn 8093;
admin.lingshivip.cn 8094;
}
server {
listen 80;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 80;
server_name api.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name admin.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi.access.log main;
error_log /var/log/nginx/dukang/lingshi.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_lingshi_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-135
View File
@@ -1,135 +0,0 @@
# 杜康好客 — lingshivip.cn 五端 + API
# API 8090H5 8091/8092/8093admin-web 8094
map $host $dukang_lingshi_port {
user.lingshivip.cn 8091;
shop.lingshivip.cn 8092;
partner.lingshivip.cn 8093;
admin.lingshivip.cn 8094;
}
# HTTP → HTTPSH5 三端)
server {
listen 80;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
# API:证书就绪前 HTTP 直连;就绪后改走 HTTPS server
server {
listen 80;
server_name api.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
# admin 待 DNS + 证书就绪前先走 HTTP
server {
listen 80;
server_name admin.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# API 独立域名 HTTPS(证书:/etc/nginx/ssl/api.lingshivip.cn/
server {
listen 443 ssl;
server_name api.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi-api.access.log main;
error_log /var/log/nginx/dukang/lingshi-api.error.log warn;
ssl_certificate /etc/nginx/ssl/api.lingshivip.cn/fullchain.cer;
ssl_certificate_key /etc/nginx/ssl/api.lingshivip.cn/api.lingshivip.cn.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 443 ssl;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi.access.log main;
error_log /var/log/nginx/dukang/lingshi.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_lingshi_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-53
View File
@@ -1,53 +0,0 @@
# runxian.top 裸域名 — 静态公共资源(微信域名校验等)
# HTTP: 保留 MP_verify + ACME;其余跳转 HTTPS
# HTTPS: deploy/enable-runxian-apex-ssl.sh 申请证书后启用
server {
listen 80;
server_name runxian.top;
access_log /var/log/nginx/dukang/runxian-apex.access.log main;
error_log /var/log/nginx/dukang/runxian-apex.error.log warn;
root /opt/dukang-haoke/public;
index index.html;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name runxian.top;
access_log /var/log/nginx/dukang/runxian-apex.access.log main;
error_log /var/log/nginx/dukang/runxian-apex.error.log warn;
ssl_certificate /etc/letsencrypt/live/runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
root /opt/dukang-haoke/public;
index index.html;
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
try_files $uri $uri/ =404;
}
}
-218
View File
@@ -1,218 +0,0 @@
# 杜康好客 — runxian.top HTTPS
# 执行: deploy/enable-runxian-dukang-ssl.sh
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/
server {
listen 80;
server_name user.runxian.top shop.runxian.top partner.runxian.top m.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://user.runxian.top$request_uri;
}
}
server {
listen 443 ssl;
server_name m.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top$request_uri;
}
server {
listen 443 ssl;
server_name user.runxian.top;
access_log /var/log/nginx/dukang/runxian-h5.access.log main;
error_log /var/log/nginx/dukang/runxian-h5.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location ~ ^/MP_verify_.*\.txt$ {
root /opt/dukang-haoke/apps/mini-user/dist;
default_type text/plain;
access_log off;
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location = / {
return 302 /user/;
}
# Taro 路由页是 /pages/xxx/index;补全短路径,避免 UniversalRouter 404 白屏
location ~ ^/user/pages/([^/]+)/?$ {
return 302 /user/pages/$1/index$is_args$args;
}
location /user/ {
rewrite ^/user/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
# serve 若仍发出绝对 Location:/pages/...,补回 /user 前缀
proxy_redirect / /user/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /shop/ {
rewrite ^/shop/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8092;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /partner/ {
rewrite ^/partner/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8093;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 旧 C 端根路径书签(无 /user 前缀)→ /user/...
location / {
return 302 /user$request_uri;
}
}
server {
listen 443 ssl;
server_name shop.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top/shop$request_uri;
}
server {
listen 443 ssl;
server_name partner.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top/partner$request_uri;
}
server {
listen 80;
server_name dkapi.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
include /opt/dukang-haoke/deploy/nginx-deploy-webhook.conf;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name dkapi.runxian.top;
access_log /var/log/nginx/dukang/runxian-api.access.log main;
error_log /var/log/nginx/dukang/runxian-api.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
include /opt/dukang-haoke/deploy/nginx-deploy-webhook.conf;
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name webadmin.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name webadmin.runxian.top;
access_log /var/log/nginx/dukang/runxian-admin.access.log main;
error_log /var/log/nginx/dukang/runxian-admin.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-154
View File
@@ -1,154 +0,0 @@
# 杜康好客 — runxian.topHTTP
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/(微信网页授权单域名)
# shop/partner 子域名 301 到 user.runxian.top 路径;webadmin → 8094dkapi → 8090
server {
listen 80;
server_name user.runxian.top;
access_log /var/log/nginx/dukang/runxian-h5.access.log main;
error_log /var/log/nginx/dukang/runxian-h5.error.log warn;
client_max_body_size 20m;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location ~ ^/MP_verify_.*\.txt$ {
root /opt/dukang-haoke/apps/mini-user/dist;
default_type text/plain;
access_log off;
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location = / {
return 302 /user/;
}
location ~ ^/user/pages/([^/]+)/?$ {
return 302 /user/pages/$1/index$is_args$args;
}
location /user/ {
rewrite ^/user/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_redirect / /user/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /shop/ {
rewrite ^/shop/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8092;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /partner/ {
rewrite ^/partner/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8093;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
return 302 /user$request_uri;
}
}
server {
listen 80;
server_name shop.runxian.top;
return 301 http://user.runxian.top/shop$request_uri;
}
server {
listen 80;
server_name partner.runxian.top;
return 301 http://user.runxian.top/partner$request_uri;
}
server {
listen 80;
server_name m.runxian.top;
return 301 http://user.runxian.top$request_uri;
}
server {
listen 80;
server_name dkapi.runxian.top;
access_log /var/log/nginx/dukang/runxian-api.access.log main;
error_log /var/log/nginx/dukang/runxian-api.error.log warn;
client_max_body_size 20m;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name webadmin.runxian.top;
access_log /var/log/nginx/dukang/runxian-admin.access.log main;
error_log /var/log/nginx/dukang/runxian-admin.error.log warn;
client_max_body_size 20m;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# 续部署:user.runxian.top 统一路径入口 + nginx 切换
set -euo pipefail
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
H5_BASE="https://user.runxian.top"
cd "$APP_ROOT"
echo "==> 1. PM2 重启(H5 dist 已含 /user|shop|partner/ 路径)"
pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner dukang-admin-web
pm2 save
echo "==> 2. 安装 nginx HTTPS 配置"
install -m 644 "$APP_ROOT/deploy/nginx-runxian-dukang-ssl.conf" /etc/nginx/conf.d/dukang-runxian.conf
nginx -t
systemctl reload nginx
echo "==> 3. 更新 USER_H5_URL"
ENV_FILE="$APP_ROOT/server/dukang-api/.env.production"
if grep -q '^USER_H5_URL=' "$ENV_FILE"; then
sed -i "s|^USER_H5_URL=.*|USER_H5_URL=${H5_BASE}/user|" "$ENV_FILE"
else
echo "USER_H5_URL=${H5_BASE}/user" >> "$ENV_FILE"
fi
pm2 restart dukang-api
echo "==> 4. 验证"
for url in \
"${H5_BASE}/user/" \
"${H5_BASE}/shop/" \
"${H5_BASE}/partner/" \
"https://shop.runxian.top/" \
"https://dkapi.runxian.top/api/v1/health"; do
code="$(curl -sk -o /dev/null -w '%{http_code}' "$url" || echo fail)"
echo " $url -> $code"
done
echo "==> user.runxian.top 统一部署完成"
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
APP_ROOT="/opt/dukang-haoke"
DEPLOY_DIR="$APP_ROOT/deploy"
echo "==> install api deps only"
cd "$APP_ROOT"
export NODE_OPTIONS="--max-old-space-size=1024"
pnpm install --filter @dukang/api... --filter @dukang/domain --filter @dukang/shared-types
echo "==> env"
cat > "$APP_ROOT/server/dukang-api/.env.production" <<'ENV'
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="gky4CNZgwdrYoDIDy4AWBiJRa4WX2ax8STe3TEG93Ht"
JWT_EXPIRES_IN="7d"
PORT=8090
MOCK_SMS=true
MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
ENV
echo "==> prisma"
cd "$APP_ROOT/server/dukang-api"
pnpm prisma:generate
npx prisma db push --accept-data-loss
pnpm prisma:seed
echo "==> serve"
npm install -g serve
echo "==> pm2"
mkdir -p /var/log/nginx/dukang /var/www/certbot
pm2 delete dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner 2>/dev/null || true
pm2 start "$DEPLOY_DIR/ecosystem.config.cjs"
pm2 save
echo "==> ssl"
if [ ! -f /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem ]; then
certbot certonly --webroot -w /var/www/certbot \
-d user.ai-mirror.xyz -d shop.ai-mirror.xyz -d partner.ai-mirror.xyz \
--non-interactive --agree-tos -m admin@ai-mirror.xyz
fi
echo "==> nginx"
cp "$DEPLOY_DIR/nginx-ai-mirror.conf" /etc/nginx/conf.d/dukang-ai-mirror.conf
nginx -t
systemctl reload nginx
echo "==> DONE"
pm2 list
ss -tlnp | grep -E '809[0-3]' || true
curl -s -o /dev/null -w "user:%{http_code}\n" http://127.0.0.1:8091/
curl -s -o /dev/null -w "api:%{http_code}\n" http://127.0.0.1:8090/api/v1/health 2>/dev/null || curl -s -o /dev/null -w "api:%{http_code}\n" http://127.0.0.1:8090/
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
APP_ROOT="/opt/dukang-haoke"
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "==> 1. 安装 MySQL(如未安装)"
if ! command -v mysqld &>/dev/null; then
dnf install -y mysql-server
systemctl enable --now mysqld
sleep 3
fi
echo "==> 2. 配置 MySQL 数据库"
mysql -uroot -e "CREATE DATABASE IF NOT EXISTS dukang_haoke CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" 2>/dev/null || true
mysql -uroot -e "CREATE USER IF NOT EXISTS 'dukang'@'localhost' IDENTIFIED BY 'Dukang2026!';" 2>/dev/null || \
mysql -uroot -e "ALTER USER 'dukang'@'localhost' IDENTIFIED BY 'Dukang2026!';" 2>/dev/null || true
mysql -uroot -e "GRANT ALL PRIVILEGES ON dukang_haoke.* TO 'dukang'@'localhost'; FLUSH PRIVILEGES;" 2>/dev/null || true
echo "==> 3. 启用 pnpm"
corepack enable
corepack prepare pnpm@11.2.2 --activate
echo "==> 4. 安装依赖并构建"
cd "$APP_ROOT"
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
pnpm build
echo "==> 5. 写入 API 生产环境变量"
cat > "$APP_ROOT/server/dukang-api/.env.production" <<'ENV'
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="gky4CNZgwdrYoDIDy4AWBiJRa4WX2ax8STe3TEG93Ht"
JWT_EXPIRES_IN="7d"
PORT=8090
MOCK_SMS=true
MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
ENV
echo "==> 6. 初始化数据库"
cd "$APP_ROOT/server/dukang-api"
pnpm prisma:generate
npx prisma db push --accept-data-loss
pnpm prisma:seed
echo "==> 7. 安装 serve(静态服务)"
npm install -g serve
echo "==> 8. 启动 PM2 进程"
mkdir -p /var/log/nginx/dukang /var/www/certbot
pm2 delete dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner 2>/dev/null || true
pm2 start "$DEPLOY_DIR/ecosystem.config.cjs"
pm2 save
echo "==> 9. 申请 SSL 证书"
if [ ! -f /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem ]; then
certbot certonly --webroot -w /var/www/certbot \
-d user.ai-mirror.xyz \
-d shop.ai-mirror.xyz \
-d partner.ai-mirror.xyz \
--non-interactive --agree-tos -m admin@ai-mirror.xyz || \
certbot certonly --nginx \
-d user.ai-mirror.xyz \
-d shop.ai-mirror.xyz \
-d partner.ai-mirror.xyz \
--non-interactive --agree-tos -m admin@ai-mirror.xyz
fi
echo "==> 10. 配置 Nginx"
cp "$DEPLOY_DIR/nginx-ai-mirror.conf" /etc/nginx/conf.d/dukang-ai-mirror.conf
nginx -t
systemctl reload nginx
echo "==> 部署完成"
pm2 list
ss -tlnp | grep -E '809[0-3]' || true
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# restore-prod-db.sh — 将本地 mysqldump 恢复到线上(production)数据库
#
# 仅在用户明确要求恢复生产库时使用。
#
# 用法:
# bash deploy/restore-prod-db.sh backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
# bash deploy/restore-prod-db.sh --yes backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
# bash deploy/restore-prod-db.sh --dry-run backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
LOCAL_CONTAINER="dukang-v1-mysql"
TUNNEL_PORT=6021
HOST_ALIAS="host.docker.internal"
ASSUME_YES=0
DRY_RUN=0
DUMP=""
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
--help|-h) sed -n '3,14p' "$0"; exit 0 ;;
--tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;;
--container=*) LOCAL_CONTAINER="${arg#*=}" ;;
--host-alias=*) HOST_ALIAS="${arg#*=}" ;;
-*) echo "未知参数: $arg" >&2; exit 2 ;;
*) DUMP="$arg" ;;
esac
done
if [[ -z "$DUMP" ]]; then
echo "请指定 dump 文件,例如: bash deploy/restore-prod-db.sh backups/prod-dukang_prod-20260823-202044.sql" >&2
exit 2
fi
if [[ ! -f "$DUMP" ]]; then
echo "文件不存在: $DUMP" >&2
exit 1
fi
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"
echo "==> 读取线上数据库配置 ($TARGET)"
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
echo "恢复计划:"
echo " 源文件: $DUMP ($(wc -c < "$DUMP") bytes)"
echo " 目标(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run) 已结束,未做任何修改。"
exit 0
fi
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
echo "本地容器 $LOCAL_CONTAINER 未运行。" >&2
exit 1
fi
if [[ $ASSUME_YES -ne 1 ]]; then
read -r -p "确认用该 dump 覆盖【线上 $REMOTE_DB】? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; }
fi
CTL="/tmp/restore-prod-db-$$.sock"
echo "==> 建立 SSH 隧道"
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
cleanup_tunnel() {
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
}
trap cleanup_tunnel EXIT
ready=0
for _ 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
exit 1
fi
echo "==> 导入 dump 到线上 $REMOTE_DB ..."
docker exec -i -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysql -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \
--default-character-set=utf8mb4 < "$DUMP"
echo "==> 恢复完成 ✅ 线上 $REMOTE_DB 已回滚到 $DUMP"
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# run-prod-cleanup.sh — 经 SSH 隧道连接生产库,执行测试数据清理
#
# 用法:
# bash deploy/run-prod-cleanup.sh # dry-run
# bash deploy/run-prod-cleanup.sh --apply --yes # 写库(默认带 --force 处理已打款测试核销)
# bash deploy/run-prod-cleanup.sh --inventory # 仅盘点
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$SCRIPT_DIR"
LOCAL_CONTAINER="dukang-v1-mysql"
TUNNEL_PORT=6020
DOCKER_HOST_ALIAS="host.docker.internal"
CLIENT_HOST="127.0.0.1"
APPLY=0
ASSUME_YES=0
FORCE=1
INVENTORY=0
SCRIPT_NAME=""
EXTRA_ARGS=()
for arg in "$@"; do
case "$arg" in
--apply) APPLY=1 ;;
--yes|-y) ASSUME_YES=1 ;;
--no-force) FORCE=0 ;;
--force) FORCE=1 ;;
--inventory) INVENTORY=1 ;;
--script=*) SCRIPT_NAME="${arg#*=}" ;;
--help|-h)
sed -n '3,12p' "$0"
exit 0
;;
*) EXTRA_ARGS+=("$arg") ;;
esac
done
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"
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
echo "本地容器 $LOCAL_CONTAINER 未运行。" >&2
exit 1
fi
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
)"
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)"
CTL="/tmp/run-prod-cleanup-$$.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"
cleanup_tunnel() {
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
}
trap cleanup_tunnel EXIT
ready=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqladmin -h "$DOCKER_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
exit 1
fi
ENC_PASS="$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$REMOTE_PASS")"
export DATABASE_URL="mysql://${REMOTE_USER}:${ENC_PASS}@${CLIENT_HOST}:${TUNNEL_PORT}/${REMOTE_DB}?charset=utf8mb4"
export FORCE_DATABASE_URL=1
cd "$REPO_ROOT/server/dukang-api"
CMD=(pnpm)
if [[ -n "$SCRIPT_NAME" ]]; then
CMD+=(ts-node --transpile-only "scripts/${SCRIPT_NAME}.ts")
[[ $FORCE -eq 1 ]] && CMD+=(--force)
[[ $APPLY -eq 1 ]] && CMD+=(--apply)
[[ $ASSUME_YES -eq 1 ]] && CMD+=(--yes)
[[ ${#EXTRA_ARGS[@]} -gt 0 ]] && CMD+=("${EXTRA_ARGS[@]}")
elif [[ $INVENTORY -eq 1 ]]; then
CMD+=(inventory:test-data -- "${EXTRA_ARGS[@]}")
else
CMD+=(cleanup:test-data --)
[[ $FORCE -eq 1 ]] && CMD+=(--force)
[[ $APPLY -eq 1 ]] && CMD+=(--apply)
[[ $ASSUME_YES -eq 1 ]] && CMD+=(--yes)
CMD+=("${EXTRA_ARGS[@]}")
fi
echo "==> 执行: DATABASE_URL=*** ${CMD[*]}"
"${CMD[@]}"
-4
View File
@@ -1,4 +0,0 @@
# 服务器机器相关(可合并到 .env.production 的参考项)
PORT=8090
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
+8 -22
View File
@@ -2,7 +2,7 @@
# 在服务器上执行:安装 Webhook 自动发版
set -euo pipefail
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
APP_ROOT="${APP_ROOT:-/opt/dukang}"
DEPLOY_DIR="$APP_ROOT/deploy"
ENV_FILE="$DEPLOY_DIR/auto-release.env"
LOG_DIR="/var/log/dukang"
@@ -30,28 +30,14 @@ HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
rm -f /etc/nginx/conf.d/dukang-deploy-webhook.conf
MARKER="include $HOOK_CONF;"
for conf in \
/etc/nginx/sites-available/dukang \
/etc/nginx/conf.d/dukang-runxian.conf \
/etc/nginx/conf.d/dukang-runxian-ssl.conf
do
if [[ ! -f "$conf" ]]; then
continue
NGINX_SITE="/etc/nginx/sites-available/dukang"
if [[ -f "$NGINX_SITE" ]]; then
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$NGINX_SITE"
if grep -q 'server_name api.dukanghaoke.com' "$NGINX_SITE" && ! grep -qF "$MARKER" "$NGINX_SITE"; then
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$NGINX_SITE"
echo " 已 patch $NGINX_SITE (api.dukanghaoke.com)"
fi
# 清理旧错误 include
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
if grep -q 'server_name api.dukanghaoke.com' "$conf"; then
if ! grep -qF "$MARKER" "$conf"; then
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$conf"
echo " 已 patch $conf (api.dukanghaoke.com)"
fi
elif grep -q 'server_name dkapi.runxian.top' "$conf"; then
if ! grep -qF "$MARKER" "$conf"; then
sed -i "/server_name dkapi.runxian.top;/a\\ $MARKER" "$conf"
echo " 已 patch $conf (dkapi.runxian.top)"
fi
fi
done
fi
nginx -t
systemctl reload nginx
+14
View File
@@ -0,0 +1,14 @@
# 测试数据清理(通用)
按 HQ 白名单 + `isTest` 打标清理,**不写死手机号/订单号**。dump 落在 `deploy/backups/`(已 gitignore)。
## 流程
1. 备份:`bash deploy/backup-prod-db.sh --yes`;建议同时在 RDS 控制台打手动快照
2. 同步到本地演练:`bash deploy/sync-prod-db-to-local.sh --yes`
3. 盘点(默认本地 `.env`):`pnpm --dir server/dukang-api inventory:test-data`
4. 本地 dry-run / apply`pnpm --dir server/dukang-api cleanup:test-data`,确认后加 `--apply --yes`
5. 生产须口头允许后再执行:`bash deploy/run-prod-cleanup.sh`dry-run)→ `--apply --yes`
6. 回滚:`bash deploy/restore-prod-db.sh --yes backups/prod-<库名>-<时间>.sql`
已打款且混有测试流水时需 `--force`。有交叉风险(测试合伙人下仍有正式门店等)会中止。
+19 -1
View File
@@ -23,6 +23,10 @@
| 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) |
| 3.5.3 | 08-20 | 门店照片/企微审核/分享;核销金额序列化与提现补建;测试流水计结算;核销浮动提示;权益金额门店图标 | [`v3.5.3`](./杜康好客-v3.5.3-开发文档.md) |
| 3.5.4 | 08-21 | 商品 SPU+SKU 规格;DK 码/独立 69 码/规格主图;下单兼容回落;发票品类;权益文案与门店详情/收银台 UI;客户端版本号 | [`v3.5.4`](./杜康好客-v3.5.4-开发文档.md) |
| 3.5.5 | 08-23 | HQ 订单监控导出 Excel/PDF + 列表页布局优化 | [`v3.5.5`](./杜康好客-v3.5.5-开发文档.md) |
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
| 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) |
---
@@ -68,6 +72,13 @@
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
**HQ 订单导出**`admin-web` 订单监控):
- 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
- 格式:Excel`.xlsx`/ PDF`.pdf`
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
- 权限:`orders`;操作审计 `ORDER_EXPORT`
### 3.3 佣金与结算
| 类型 | 默认 | 释放 |
@@ -80,6 +91,13 @@
**FIN-001~003**:白名单未出账提现 · 单店日限 ¥5000 · T+0 审完预警。
合伙人 **T+30** 独立月账/打款。
**HQ 财务打款信息**`admin-web` 财务四页,v3.5.6):
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
- 核销详情展示核销门店主账户收款信息
### 3.4~3.8 Wave 能力
- **3.4** 门店一号多店、主账号、店员子账号(W2)
@@ -93,7 +111,7 @@
| § | 主题 | 文档 |
|---|------|------|
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
| 3.10 | 开发计划/任务/版本/技术支持联动;任务导出 md/docx/xlsx/pdf;任务附件 | v3.4.11 / v3.5.6 |
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 |
+11 -3
View File
@@ -9,7 +9,7 @@
|------|------|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
| C 端 | `mini-user` 小程序 + h5-userv3.4.13 版本门控/门店/物流已上 |
| 近期版本 | … · v3.4.16 · **v3.5.3 门店照片/企微审核/分享 + 核销金额/提现补修 + 权益图标** |
| 近期版本 | … · v3.5.3 · **v3.5.4 商品规格 SPU+SKU + 发票品类 + 权益/门店详情 UI** |
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
@@ -32,7 +32,7 @@
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
| **admin-web** | 商品/开城/门店/订单/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
| **admin-web** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
## 3. 场景 SC-01~09
@@ -57,10 +57,18 @@
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | 🔶 开发中 |
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | ✅ 生产 |
| 3.5.4 | [`商品规格 SPU+SKU + 发票/文案 UI`](./杜康好客-v3.5.4-开发文档.md) | ✅ 生产(weapp 版本号需单独上传) |
| 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 |
| 3.5.6 | [`HQ 任务导出/财务银行信息/工单图片/运营去调试/审核全屏`](./杜康好客-v3.5.6-开发文档.md) | ✅ 已交付 |
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
| 日期 | 说明 |
|------|------|
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
| 2026-08-23 | HQ 订单监控:筛选/勾选导出 Excel(xlsx)与 PDF`POST /admin/orders/export` |
| 2026-08-21 | v3.5.4 发生产:规格表/回填 SKU/DK 码/主图列/发票品类;H5+API 200 |
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态 |
| 2026-08-07 | v3.4.14 增补统一测试白名单(限测可见 / Mock 旁路;测试流水计入结算) |
+49 -2
View File
@@ -1,7 +1,7 @@
# 杜康好客 · v3.5.3 版本更新
> **2026-08-20** · admin-web / mini-user / h5-partner / h5-shop / API
> 目标:门店封面/环境图体验、企微门店审核通知、总部抽屉内审套餐、小程序分享用业务标题主图、系统设置分享配置按场景折叠;**补修核销金额为 0 / 无法提现、测试流水计结算、核销浮动提示、好客权益金额图标**。
> 目标:门店封面/环境图体验、企微门店审核通知、总部抽屉内审套餐、小程序分享用业务标题主图、系统设置分享配置按场景折叠;**补修核销金额为 0 / 无法提现、测试流水计结算、核销浮动提示、好客权益金额图标**;**企微业务通知可编辑模板 + 处理快链**
## 范围
@@ -19,8 +19,9 @@
| 8 | 测试流水计结算 | **取消**「测试流水不计结算」;核销一律建 payout;账单/佣金不再因 `isTest` 跳过 |
| 9 | 核销校验浮动提示 | 门店 H5 / 小程序:超可用余额等提示改为页面中上部浮动气泡 |
| 10 | 好客权益金额图标 | 小程序权益金额前去掉 ¥,改为门店核销语义图标(商品售价/实付仍用 ¥) |
| 11 | 企微业务通知扩展 | 订单支付/核销成功/信息变更/提现/发票;`wecom_push_template` 可编辑;处理快链 |
**配置进库**`wecom_message_push` upsert「门店审核通知群」(无 webhook 时占位 URL + `enabled=false`)。无新 Prisma 表
**配置进库**`wecom_message_push` upsert「门店审核通知群 / 业务待办通知群 / 成交播报群」(无 webhook 时占位 URL + `enabled=false`)。**新表** `wecom_push_template`(发版不可 skip-db
---
@@ -138,6 +139,48 @@
---
## 11. 企微业务通知 + 可编辑模板 + 处理快链
### 事件条件 key
| key | 触发 | 测试单 |
|-----|------|--------|
| `order.paid` | `afterOrderPaid` | 不推 |
| `redeem.success` | `executeRedeem` 成功后 | 不推 |
| `store.audit_pending` | 入驻/重提 PENDING | 推 |
| `store.package_audit_pending` | 套餐变更 PENDING | 推 |
| `store.info_change_pending` | 信息变更 PENDING | 推 |
| `store.withdraw_pending` | 手动提现 PENDING_REVIEW | 推 |
| `invoice.pending` | 发票申请 PENDING | 不推 |
默认推送行:`业务待办通知群``成交播报群`(占位 webhook + 禁用,直至 HQ 配置)。
### 模板表 `wecom_push_template`
-`eventKey` 全站一份;启动 `ensureTemplates` **仅插入缺行**,不覆盖 HQ 已改
- HQ「企微机器人 → 消息推送 → 通知模板」:编辑 / 恢复默认 / 示例测试推送
- API`GET/PUT /admin/wecom-push-templates/:eventKey``POST .../reset``POST .../test`
- 渲染:`WecomMessagePushService.dispatchEvent``{{var}}` 插值 → 条件路由
### 处理快链
环境变量 `HQ_ADMIN_PUBLIC_URL`staging `https://admin-test.dukanghaoke.com`,生产 `https://admin.dukanghaoke.com`)。
未配置时按 `WECOM_ALERT_ENV_LABEL` 回退(prod→生产域名,staging→测试域名,local→`http://localhost:5175`),**禁止**只发相对路径(企微会解析成 `http://orders/...`)。
| 事件 | 路径 |
|------|------|
| 订单 | `/orders?orderNo=` |
| 核销 | `/redeem-records?redeemNo=` |
| 入驻 | `/stores?auditStatus=PENDING&storeId=` |
| 套餐 | `/store-package-audits?requestId=` |
| 信息变更 | `/store-package-audits?tab=info&infoRequestId=` |
| 提现 | `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=` |
| 发票 | `/invoices?status=PENDING&invoiceNo=` |
提现待审改走 `store.withdraw_pending`,不再误用 `AlertService` `category: finance``alert.system`
---
## 验收
- [ ] 合伙人可改门头/环境图(PENDING 除外)
@@ -151,3 +194,7 @@
- [ ] 历史无 payout 的核销:打开「结算提现」后可用余额出现;测试核销同样计结算
- [ ] 门店 H5 / 小程序超额核销提示为中上部浮动气泡
- [ ] 小程序好客权益金额前为店铺图标,商品价仍为 ¥
- [ ] HQ 可编辑企微通知模板;勾选条件 + 配置 webhook 后订单/核销/提现/发票/审核有推送
- [ ] 企微消息「去处理」可打开 HQ 对应列表并尽量定位到该单(需登录)
- [ ] 测试订单/核销不推成交播报;发票申请测试单不推
- [ ] 发版含 `wecom_push_template` 表与 `HQ_ADMIN_PUBLIC_URL`
+145
View File
@@ -0,0 +1,145 @@
# 杜康好客 · v3.5.4 开发文档
> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma
> **生产 commit**`5fdddae`(规格主图与门店使用规则合并入 `main`)及后续客户端版本号 `3.5.4`
> **主题**:商品规格(SPU + SKU)上线 + 同批次发票 / 文案 / 门店详情体验
---
## 1. 版本目标
1. 商品由单规格升级为 **SPU + 多 SKU**:价格、权益、69 码、履约、上下架、主图按规格。
2. 箱装按 **瓶当量** 起购与物流,不改城市起购阈值口径。
3. **旧客户端兼容**:不传 `skuId` 仍可下单;无 SKU 回落 SPU,禁止「商品暂无可售规格」误伤。
4. 同批次补齐:发票品类、权益文案、收银台 logo、门店详情「使用规则」、C 端版本号。
**不做**:SKU 库存、把现有酒祖 10/15/20 合并为一个 SPU。
---
## 2. 功能清单(完整)
### 2.1 商品规格(核心)
| # | 模块 | 交付说明 |
|---|------|----------|
| 1 | 数据模型 | `common_product_item` 作 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
| 2 | 存量迁移 | 每商品默认 1 个瓶装 SKU;SQL `migrate-product-sku-v354.sql`;脚本 `backfill-product-skus.ts` |
| 3 | Catalog | 列表 `specEnabled`/`saleUnit`;详情 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 |
| 4 | Trade | preview/create 可选 `skuId`;有可售 SKU → 默认/唯一;**0 可售 → 回落 SPU**;订单 `sku_id` 可空 |
| 5 | Domain | 起购/大单/物流按 `quantity × bottlesPerUnit` 瓶当量 |
| 6 | Admin | `PUT .../specs``PUT .../skus`;抽屉加宽;「填写」弹窗;规格轴 + SKU 矩阵 |
| 7 | mini-user | 详情规格 chips;确认页带 `skuId`;箱装数量文案;选规格切主图 |
| 8 | 代下单 | HQ / 合伙人可选规格 SKU |
### 2.2 SKU 码与 69 码
| # | 项 | 说明 |
|---|----|------|
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可手填;脚本 `rewrite-sku-codes-dk.ts` 改存量 |
| 10 | 69 码 | **每规格独立**;同商品内 + 跨商品唯一;不可与其它 SKU 冲突 |
### 2.3 规格主图
| # | 项 | 说明 |
|---|----|------|
| 11 | DB | `common_product_sku.image_url``migrate-sku-image.sql` |
| 12 | Admin | 弹窗可为每 SKU 上传主图 |
| 13 | C 端 | 选规格优先 SKU 主图,空则回落商品封面 |
### 2.4 发票(同批次)
| # | 项 | 说明 |
|---|----|------|
| 14 | 品类 | `user_invoice.invoice_category``LIQUOR` 酒水类 / `CATERING` 餐饮类 |
| 15 | C 端申请 | 票种写死「增值税普通发票」;类型选酒水/餐饮;备注写入申请 |
| 16 | 抬头 | 必填项打 `*`;强调邮箱必填 |
| 17 | Admin | 发票列表展示品类;专票仍可走后台 |
### 2.5 文案与 UI(同批次)
| # | 位置 | 变更 |
|---|------|------|
| 18 | 全局权益文案 | 「好客权益券」→「好客权益」 |
| 19 | 首页角标 | 「享{amount}好客权益」,去掉门店 icon |
| 20 | 商品详情 | 规格区与「好客权益」说明加大间距 |
| 21 | 待支付/收银台 | 顶部 logo 用 `logo2.png`(杜康印章) |
| 22 | 门店详情 | 「使用规则」红色,置于「门店详情」上方;详情正文限高可滚动 |
| 23 | 客户端版本号 | `package.json` + `client-version.ts`**3.5.4**(「我的」页展示) |
---
## 3. 兼容规则(线上)
- 不改 `/api/v1`**只增字段,不删旧字段语义**。
- 旧客户端不传 `skuId`
- ≥1 个可售 SKU → 默认(或第一个)可售 SKU
- **0 个可售 SKU → 回落 SPU,照常下单**
- 新客户端传 `skuId` → 按该 SKU 校验。
- 旧 admin 不传规格:仅同步**唯一**默认 SKU;不覆盖已生成的 DK 码。
- SPU 上 `sku_code` / `barcode_69` 唯一约束下沉到 SKU;SPU 列保留冗余。
---
## 4. 发版与生产执行记录
按序在目标库执行(先测后产)。本机脚本只打本地 `.env`
| 步骤 | 动作 | 生产状态(2026-08-21 |
|------|------|------------------------|
| 1 | `migrate-product-sku-v354.sql` / Prisma sync | ✅ `db push` 已对齐 |
| 2 | `backfill-product-skus.ts` | ✅ 回填 9 个默认 SKU(扫 10 商品) |
| 3 | `migrate-sku-image.sql` / schema `image_url` | ✅ |
| 4 | `rewrite-sku-codes-dk.ts` | ✅ 13 条均已 DK,改写 0 |
| 5 | `migrate-invoice-category.sql` | ✅ schema 已含 |
| 6 | 发 API + mini-user H5 + admin | ✅ `mini-user 200` / `api 200` |
| 7 | 微信小程序 weapp 上传 | ⚠️ 需单独上传后「我的」页才见 v3.5.4 |
运营注意:新包上线前**不要**给商品加第二规格;上线后再配「单瓶 / 整箱」。
---
## 5. 起购与物流
- `bottleQty = quantity × bottlesPerUnit`
- 城市起购仍按瓶;箱装(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6
- 大单拦截、承运商 `goodsNum` 用瓶当量
---
## 6. 关键表 / 字段摘要
- `common_product_spec_attr` / `common_product_spec_value`
- `common_product_sku``sale_unit``bottles_per_unit``image_url`
- `common_product_sku_spec`
- `user_order.sku_id` / `sale_unit` / `bottles_per_unit`
- `user_invoice.invoice_category`
---
## 7. 关键路径(便于排查)
| 域 | 路径 |
|----|------|
| Prisma / 脚本 | `server/dukang-api/prisma/migrate-product-sku-v354.sql``backfill-product-skus.ts``rewrite-sku-codes-dk.ts``migrate-sku-image.sql``migrate-invoice-category.sql` |
| 规格工具 | `server/.../catalog/product-sku.util.ts` |
| Admin 规格 | `apps/admin-web/src/components/ProductSpecsEditor.tsx` |
| C 端详情 | `apps/mini-user/src/pages/product-detail/` |
| 版本号 | `apps/mini-user/src/lib/client-version.ts` |
| 门店详情 | `apps/mini-user/src/pages/store-detail/` |
| 发票 | `apps/mini-user/src/pages/invoice-apply/``invoice-titles/` |
---
## 8. 验收清单
- [ ] 未配规格:旧端下单与现网一致(无 SKU 也能下)
- [ ] 多规格:选规格后价格/权益/履约/主图变化;漏选 400
- [ ] 整箱:数量 1 过起购;订单 `bottles_per_unit=6`
- [ ] 详情 API 仍含 `id/name/price/spec/skuCode/...`
- [ ] 后台 SKU 码 DK 开头不可手填;每规格独立 69 码与主图
- [ ] 无「好客权益券」;首页角标「享{金额}好客权益」无门店 icon
- [ ] 门店详情「使用规则」红色且在详情上方;详情超长可滚
- [ ] 待支付页为 logo2 印章
- [ ] 发票:普票固定 + 酒水/餐饮品类 + 邮箱必填
- [ ] 「我的」页版本 **杜康好客 v3.5.4**(需 weapp 新包)
+170
View File
@@ -0,0 +1,170 @@
# 杜康好客 · v3.5.5 开发文档
> **2026-08-23** · admin-web / API
> **主题**HQ 订单监控导出(Excel / PDF)+ 订单列表页布局优化
---
## 1. 版本目标
1. 总部 **订单监控** 支持按筛选或勾选导出 **Excel.xlsx** / **PDF.pdf**
2. 导出内容聚焦运营履约:**商品明细、收货信息、好客权益摘要**;次要字段从列表与导出列中精简。
3. **订单列表页** 筛选区改为网格布局,支持日期快捷选择;导出、查询、重置、批量删除同一操作行。
4. 修复现场取货订单地址展示为「现场现场取货现场取货」的问题。
**不做**:C 端 / 合伙人 / 门店端改动;无 Prisma 迁移。
---
## 2. 功能清单
### 2.1 HQ 订单导出(核心)
| # | 模块 | 交付说明 |
|---|------|----------|
| 1 | API | `POST /api/v1/admin/orders/export` — 生成文件,响应 `{ filename, mimeType, contentBase64, count }` |
| 2 | 预览 | `POST /api/v1/admin/orders/export/preview` — 返回 `{ count, max, exceeds }`(上限 5000 |
| 3 | 导出范围 | `scope=filter` 按当前筛选;`scope=selected` 仅导出勾选 `ids` |
| 4 | 格式 | `format=xlsx`exceljs/ `format=pdf`pdfkit,横向 A4 |
| 5 | 权限 | `HqAuthGuard` + `orders`;操作审计 `ORDER_EXPORT` |
| 6 | 筛选复用 | `buildOrderWhere()` 与列表查询共用;新增 `deliveryType` 筛选 |
**导出列**11 列):
订单号、下单时间、状态、商品、规格、数量、实付、好客权益、收货人、手机、收货地址。
好客权益列:有权益券时 `总额¥X / 已用¥Y / 余¥Z`;否则回落订单 `benefitAmount`
### 2.2 订单列表页(admin-web
| # | 项 | 说明 |
|---|----|------|
| 7 | 筛选布局 | 网格表单:下单日期、状态、类型、配送、城市、收货手机;大单拦截 / 过滤测试 |
| 8 | 日期快捷 | 当日、当周(周一至周日)、当月、当季、当年 |
| 9 | 列表列 | 商品(含规格/数量/标签)、状态+实付、好客权益、收货信息、下单时间、操作 |
| 10 | 勾选导出 | 列表常驻勾选;左侧 Excel/PDF +「导出已勾选」「导出全部筛选」 |
| 11 | 操作行 | 右侧同一行:查询、重置、批量删除(有权限时) |
| 12 | 现场取货 | 收货地址识别 `ON_SITE_PICKUP` 或省市区占位后,统一显示「现场取货」 |
### 2.3 列表数据增强
| # | 项 | 说明 |
|---|----|------|
| 13 | 权益关联 | `GET /admin/orders` include `benefitCoupon`(券号、总额/已用/余额、状态) |
| 14 | 收货字段 | `AdminOrderRow` 增加省市区、地址、`benefitAmount``benefitCoupon` 类型 |
---
## 3. API 契约
### 3.1 导出请求体 `AdminOrdersExportDto`
```json
{
"scope": "filter",
"format": "xlsx",
"ids": ["123"],
"status": "PENDING_SHIP",
"orderType": "NORMAL",
"cityId": "1",
"receiverPhone": "138",
"deliveryType": "LOCAL",
"fulfillmentHold": true,
"excludeTest": true,
"createdFrom": "2026-08-01",
"createdTo": "2026-08-23"
}
```
| 字段 | 说明 |
|------|------|
| `scope` | `filter` \| `selected`(勾选时仅传 `ids`,忽略其它筛选) |
| `format` | `xlsx` \| `pdf` |
| `createdFrom` / `createdTo` | 按筛选导出且均未填时,服务端默认近 30 天 |
### 3.2 错误码
| 场景 | HTTP | message |
|------|------|---------|
| 超过 5000 条 | 400 | 超过 5000 条,请缩小日期或筛选条件 |
| 无数据 | 400 | 没有可导出的订单 |
| 勾选为空 | 400 | 请先勾选要导出的订单 |
| PDF 无字体 | 500 | 未找到可用于 PDF 的中文字体… |
### 3.3 列表查询增量
`GET /admin/orders` 查询参数新增(与导出筛选一致):
- `deliveryType``LOCAL` \| `CROSS_CITY` \| `ON_SITE_PICKUP`
- `createdFrom` / `createdTo`:下单日期(已有 DTO,本版接 UI)
---
## 4. 依赖与部署
### 4.1 新增 npm 依赖(仅 API
- `exceljs` — xlsx 生成
- `pdfkit` + `@types/pdfkit` — pdf 生成
### 4.2 PDF 中文字体
路径:`server/dukang-api/assets/fonts/`(见 `README.md`)。
解析顺序:
1. 环境变量 `EXPORT_PDF_FONT_PATH`
2. `assets/fonts/NotoSansSC-Regular.otf` / `simhei.ttf` / `msyh.ttc`
3. Linux 系统 Noto CJK
4. Windows 系统字体(开发机)
> 字体二进制已加入 `.gitignore`;生产部署需放置字体或安装 `fonts-noto-cjk`。
### 4.3 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | `pnpm install`(根目录,拉取 exceljs/pdfkit |
| 2 | 确认 `server/dukang-api/assets/fonts/` 有中文字体(PDF 必需) |
| 3 | `pnpm build`API + admin-web |
| 4 | 发 `dukang-api` + `admin-web`**无需** `prisma migrate` |
---
## 5. 关键路径(便于排查)
| 域 | 路径 |
|----|------|
| 导出工具 | `server/dukang-api/src/modules/ops/admin-order-export.util.ts` |
| 订单服务 | `server/dukang-api/src/modules/ops/admin-orders.service.ts` |
| 控制器 | `server/dukang-api/src/modules/ops/admin-orders.controller.ts` |
| DTO | `server/dukang-api/src/modules/ops/dto/admin-query.dto.ts` |
| 审计常量 | `server/dukang-api/src/common/hq-operation/hq-operation.constants.ts``ORDER_EXPORT` |
| 前端页面 | `apps/admin-web/src/pages/OrdersPage.tsx` |
| 下载工具 | `apps/admin-web/src/lib/exportExcel.ts``downloadBase64File` |
| 类型 | `apps/admin-web/src/lib/api.ts``AdminOrderRow` |
| 需求 | `docs/杜康好客-v3-PRD.md` §3.2 HQ 订单导出 |
| 现状 | `docs/杜康好客-v3-现状对照.md` |
---
## 6. 验收清单
- [ ] 订单列表:网格筛选 + 日期快捷(当日/当周/当月/当季/当年)可用
- [ ] 列表展示:商品(规格×数量)、状态/实付、好客权益、收货人+手机+地址
- [ ] 现场取货订单地址仅显示「现场取货」,不出现重复拼接
- [ ] 勾选若干订单 →「导出已勾选」→ xlsx / pdf 条数与勾选一致
- [ ] 设筛选条件 →「导出全部筛选」→ 文件含筛选范围内全部订单(≤5000)
- [ ] 超 5000 条时导出全部筛选返回明确错误
- [ ] Excel / PDF 中文正常;PDF 表头与分页可读
- [ ]`orders` 权限账号无法调用导出接口
- [ ] HQ 操作日志有 `ORDER_EXPORT` 记录
- [ ] 无删除权限账号仍可勾选并导出;有删除权限时批量删除与导出/查询同一行
---
## 7. 与 v3.5.4 关系
- **仅 HQ 内部工具**,不影响 C 端小程序版本号。
- 商品规格(SPU/SKU)、发票等 v3.5.4 能力保持不变。
- PRD §3.2 已补充 HQ 订单导出业务规则;本版为实现与 UI 落地说明。
+129
View File
@@ -0,0 +1,129 @@
# 杜康好客 · v3.5.6 开发文档
> **2026-08-23** · admin-web / API
> **主题**:HQ 运营与财务体验增强(任务导出、核销/账单银行信息、工单图片、权限、审核全屏)
---
## 1. 版本目标
1. **开发计划** 任务列表支持导出 Markdown / DOCX / Excel / PDF。
2. **好客权益 · 核销详情** 展示核销门店收款账户(户名/账号/开户行)。
3. **门店账单** 保留「已打款」「已结算」文案,用颜色与类型标签区分;页头说明两类终态含义。
4. **财务四页**(门店/合伙人/酒厂/物流)统一展示收款账户与打款凭证;确认打款可填凭证号。
5. **技术支持 → 开发任务** 审批创建任务时带入工单图片;新建任务支持上传图片。
6. **运营权限** 去掉调试模块(核销调试、小飞侠联调),仅超级管理员可见。
7. **门店审核对比** 支持全屏查看详情。
**不做**:C 端 / 合伙人 / 门店端改动;不升小程序版本号。
---
## 2. 功能清单
### 2.1 开发计划任务导出
| # | 项 | 说明 |
|---|----|------|
| 1 | API | `POST /api/v1/admin/dev-plan/tasks/export` |
| 2 | 范围 | `scope=filter` 按筛选;`scope=selected` 仅勾选 `ids` |
| 3 | 格式 | `markdown` / `docx` / `xlsx` / `pdf` |
| 4 | 列 | 任务编号、类型、状态、内容、关联工单、创建人、创建时间、完成时间、附件 |
| 5 | 权限 | `dev_plan` |
### 2.2 核销详情 · 门店收款账户
| # | 项 | 说明 |
|---|----|------|
| 6 | API | `GET /admin/redeem-records/:id` 增加 `store.primaryAccount` |
| 7 | UI | `RedeemRecordDetailDescriptions` 增加「收款账户」区块 |
### 2.3 门店账单状态区分
| 类型 | 终态 | 颜色 | 含义 |
|------|------|------|------|
| T+1 出账 | 已打款 | 绿 | HQ 确认已对公打款 |
| 手动提现 | 已结算 | 青 | HQ 审核通过并完成结算 |
两类单据终态枚举均为 `PAID`,故文案不同;本版不改文案,仅强化视觉区分。
### 2.4 财务四页 · 银行卡打款信息
| 页 | 收款账户来源 | 打款凭证 |
|----|-------------|----------|
| 门店账单 | 主 `StoreAccount` | `StoreBill.paymentRef`T+1);提现沿用 `StoreWithdrawRequest` |
| 合伙人账单 | `PartnerAccount` | `PartnerBill.paymentRef` |
| 酒厂账单 | `WINERY_BANK_*` 系统配置 | `WineryBill.paymentRef` |
| 物流对账 | `FulfillmentProvider` | `LogisticsBill.paymentRef`(挂账模式) |
列表 / 详情 / 导出均展示户名、账号、开户行(及酒厂开户银行);确认打款弹窗可选填凭证号。
### 2.5 工单 → 任务图片
| # | 项 | 说明 |
|---|----|------|
| 8 | DB | `dev_plan_task.attachment_urls` JSON |
| 9 | 审批创建 | 默认拷贝工单 `attachmentUrls` |
| 10 | 新建任务 | `DevPlanTasksPage` 支持 `OssUpload` |
### 2.6 运营去掉调试模块
| 菜单 | 路由 | 新权限键 |
|------|------|----------|
| 核销调试 | `/redeem/debug` | `benefit_debug` |
| 小飞侠联调 | `/deliveries/xiaofeixia` | `deliveries_debug` |
`SUPER_ADMIN` 默认拥有;调试 API 同步鉴权。
### 2.7 门店审核全屏
`StorePackageAuditsPage` 套餐对比 / 信息变更 Drawer 支持「全屏查看」切换 `width=100%`;全屏时图片放大。
---
## 3. Prisma 迁移
- `store_bill.payment_ref`
- `partner_bill.payment_ref`
- `winery_bill.payment_ref`
- `logistics_bill.payment_ref`
- `dev_plan_task.attachment_urls`
脚本:`prisma/migrate-finance-payment-ref-v356.sql``prisma/migrate-dev-plan-attachments-v356.sql`
---
## 4. 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | `pnpm install`(新增 `docx` |
| 2 | 执行 Prisma 迁移 |
| 3 | `pnpm build`API + admin-web |
| 4 | 发 `dukang-api` + `admin-web` |
---
## 5. 关键路径
| 域 | 路径 |
|----|------|
| 任务导出 | `server/.../dev-plan/admin-dev-plan-export.util.ts` |
| 核销详情 | `admin-redeem.service.ts` + `RedeemRecordDetailDescriptions.tsx` |
| 门店账单 | `StoreBillsPage.tsx` + `settlement.service.ts` |
| 合伙人/酒厂/物流 | `PartnerBillsPage` / `WineryBillsPage` / `LogisticsBillsPage` |
| 工单图片 | `dev-plan.service.ts` + `SupportTicketsPage.tsx` + `DevPlanTasksPage.tsx` |
| 权限 | `hq-permissions.ts` + `AdminLayout.tsx` |
| 审核全屏 | `StorePackageAuditsPage.tsx` + `StorePackageAuditPanel.tsx` |
---
## 6. 验收清单
- [ ] 开发计划:勾选/筛选导出 md/docx/xlsx/pdf,中文正常
- [ ] 核销详情:有主账户时展示户名/账号/开户行
- [ ] 门店账单:T+1「已打款」绿色、提现「已结算」青色;类型 Tag 醒目
- [ ] 财务四页:列表/详情/导出含收款账户;确认打款可填凭证;已打款后可见凭证
- [ ] 工单审批创建任务:图片带入;新建任务可上传图
- [ ] 运营账号无「核销调试」「小飞侠联调」菜单;直链 403
- [ ] 门店审核对比可全屏;图片放大可预览
+50
View File
@@ -0,0 +1,50 @@
# 杜康好客 · v3.5.7 开发文档
> **2026-08-23** · h5-shop
> **主题**:门店端核销页两处 UI 修复(记录时间显示秒、首页统计文案)
---
## 1. 版本目标
1. **核销记录** 列表「核销时间」显示到秒(不再截断到分钟)。
2. **门店首页** 统计卡片「今日到账金额」改为「今日核销金额」,与 `todayAmount`(当日券面核销额合计)语义一致。
**不做**:后端 API / 结算逻辑;提现页时间格式;小程序版本号;`pages/shop/` 原型。
---
## 2. 功能清单
| 工单 | 项 | 说明 |
|------|----|------|
| DPT-20260821-935 | 核销记录时间 | `RecordsPage.tsx` 去掉 `.slice(0, 16)`,保留 `toLocaleString('zh-CN', { hour12: false })` |
| DPT-20260821-796 | 首页统计文案 | `HomePage.tsx` 标签「今日到账金额」→「今日核销金额」 |
---
## 3. 关键路径
| 域 | 路径 |
|----|------|
| 核销记录 | `apps/h5-shop/src/pages/RecordsPage.tsx` |
| 门店首页 | `apps/h5-shop/src/pages/HomePage.tsx` |
---
## 4. 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | `pnpm build`h5-shop |
| 2 | 发 `h5-shop` 静态资源 |
无 Prisma 迁移、无 API 变更。
---
## 5. 验收清单
- [ ] 门店首页统计区显示「今日核销金额」,金额仍为当日券面合计
- [ ] 核销记录页每条「核销时间」含秒(如 `2026/8/23 18:48:30`
- [ ] 打款时间、金额列、筛选与 Tab 行为无回归
+26
View File
@@ -7,6 +7,8 @@ import {
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
validateMinPurchase,
toBottleQuantity,
toMinSaleQuantity,
validateBusinessHours,
formatBusinessHours,
validateRedeemAmount,
@@ -47,6 +49,30 @@ describe('validateMinPurchase', () => {
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
});
it('box SKU: 1 box (6 bottles) satisfies local and cross mins', () => {
const opts = { bottlesPerUnit: 6, saleUnit: 'BOX' as const };
expect(validateMinPurchase('LOCAL', 1, 2, 6, opts).ok).toBe(true);
expect(validateMinPurchase('CROSS_CITY', 1, 2, 6, opts).ok).toBe(true);
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6, opts).ok).toBe(true);
});
it('box SKU message uses 箱', () => {
const r = validateMinPurchase('LOCAL', 0, 2, 6, { bottlesPerUnit: 6, saleUnit: 'BOX' });
expect(r.ok).toBe(false);
expect(r.message).toContain('箱');
});
});
describe('toBottleQuantity / toMinSaleQuantity', () => {
it('converts sale qty to bottles', () => {
expect(toBottleQuantity(2, 1)).toBe(2);
expect(toBottleQuantity(1, 6)).toBe(6);
expect(toMinSaleQuantity(2, 1)).toBe(2);
expect(toMinSaleQuantity(2, 6)).toBe(1);
expect(toMinSaleQuantity(6, 6)).toBe(1);
expect(toMinSaleQuantity(7, 6)).toBe(2);
});
});
describe('validateBusinessHours', () => {
+41 -7
View File
@@ -7,27 +7,61 @@ export function calcBenefitAmount(product: ProductPricing): number {
return product.benefitAmount ?? product.price;
}
export type ProductSaleUnit = 'BOTTLE' | 'BOX';
/** 销售数量 → 瓶当量 */
export function toBottleQuantity(quantity: number, bottlesPerUnit = 1): number {
const qty = Math.floor(Number(quantity) || 0);
const per = Math.floor(Number(bottlesPerUnit) || 0);
if (qty <= 0 || per <= 0) return 0;
return qty * per;
}
/**
* 城市起购瓶数 → 最少销售单位数量(向上取整)
* 例:同城 2 瓶、整箱 6 瓶/箱 → minSaleQty = 1
*/
export function toMinSaleQuantity(minBottleQty: number, bottlesPerUnit = 1): number {
const minBottles = Math.floor(Number(minBottleQty) || 0);
const per = Math.floor(Number(bottlesPerUnit) || 0);
if (minBottles <= 0) return 1;
if (per <= 0) return minBottles;
return Math.max(1, Math.ceil(minBottles / per));
}
export function saleUnitLabel(saleUnit: ProductSaleUnit | string | null | undefined): string {
return saleUnit === 'BOX' ? '箱' : '瓶';
}
export function validateMinPurchase(
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
quantity: number,
localMinQty: number,
crossMinQty: number,
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
): { ok: boolean; message?: string } {
const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
const unit = saleUnitLabel(saleUnit);
if (deliveryType === 'ON_SITE_PICKUP') {
const min = localMinQty > 0 ? localMinQty : 2;
if (quantity < min) {
return { ok: false, message: `现场提货至少购买 ${min}` };
const minBottles = localMinQty > 0 ? localMinQty : 2;
if (bottleQty < minBottles) {
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
}
return { ok: true };
}
const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
if (quantity < min) {
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
if (bottleQty < minBottles) {
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
return {
ok: false,
message:
deliveryType === 'LOCAL'
? `同城配送至少购买 ${min}`
: `跨城配送至少购买 ${min} 瓶(1箱)`,
? `同城配送至少购买 ${minSale}${unit}`
: `跨城配送至少购买 ${minSale}${unit}`,
};
}
return { ok: true };
+79 -1
View File
@@ -8,16 +8,50 @@ export interface CityDto {
maxPartnerCommissionRate?: number;
}
export type ProductSaleUnit = 'BOTTLE' | 'BOX';
export interface ProductSpecValueDto {
id: string;
name: string;
}
export interface ProductSpecAttrDto {
id: string;
name: string;
values: ProductSpecValueDto[];
}
export interface ProductSkuDto {
id: string;
/** 规格值 id 列表(与 specAttrs 轴顺序对应) */
specValueIds: string[];
specText: string;
price: number;
benefitAmount: number;
status: string;
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
allowOnSitePickup: boolean;
saleUnit: ProductSaleUnit;
bottlesPerUnit: number;
isDefault: boolean;
/** 规格主图;无则详情页回落商品封面/轮播 */
imageUrl?: string | null;
}
export interface ProductDto {
id: string;
skuCode: string;
name: string;
subtitle?: string | null;
/** 拍平价:可售 SKU 最低价 / 默认 SKU 价 */
price: number;
benefitAmount: number;
benefitDisplay?: number;
aromaType: string;
status: string;
/** 规格文案(默认/展示 SKU) */
spec?: string;
/** 封面图(common_resource COVER / cover_resource_id */
mainImageUrl?: string | null;
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
@@ -25,12 +59,21 @@ export interface ProductDto {
/** 详情长图(bizType=DETAIL 或 detailContent JSON */
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
/** 是否允许现场取货下单 */
/** 是否允许现场取货下单(拍平自展示 SKU */
allowOnSitePickup?: boolean;
/** 是否允许配送到址(同城线上购买) */
allowOnlinePurchase?: boolean;
/** 是否允许跨城配送 */
allowCrossCityDelivery?: boolean;
/** v3.5.4:是否配置了销售规格(多 SKU) */
specEnabled?: boolean;
/** 列表轻量:展示 SKU 销售单位 */
saleUnit?: ProductSaleUnit;
/** 详情:规格轴 */
specAttrs?: ProductSpecAttrDto[];
/** 详情:可售 SKU(含 OFF_SALE 供前端灰置时可带 status */
skus?: ProductSkuDto[];
defaultSkuId?: string;
}
export interface ProductDetailFeatureDto {
@@ -68,3 +111,38 @@ export interface ProductDetailTemplateDto {
createdAt: string;
updatedAt: string;
}
/** Admin 保存规格轴 */
export interface AdminProductSpecValueInput {
/** 已有值 id;新建可省略 */
id?: string;
name: string;
sortOrder?: number;
}
export interface AdminProductSpecAttrInput {
id?: string;
name: string;
sortOrder?: number;
values: AdminProductSpecValueInput[];
}
export interface AdminProductSkuInput {
id?: string;
/** 规格值 id;无规格时为空数组 */
specValueIds?: string[];
/** 忽略;服务端自动生成 DK 码 */
skuCode?: string;
barcode69: string;
price: number;
benefitAmount?: number;
status?: string;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
saleUnit?: ProductSaleUnit;
bottlesPerUnit?: number;
isDefault?: boolean;
sortOrder?: number;
imageUrl?: string | null;
}
+23 -1
View File
@@ -92,6 +92,8 @@ export interface DevPlanTaskDto {
completedAt?: string | null;
attachmentUrls?: string[] | null;
}
@@ -148,6 +150,8 @@ export interface CreateDevPlanTaskInput {
supportTicketId?: string;
attachmentUrls?: string[];
}
@@ -160,6 +164,8 @@ export interface UpdateDevPlanTaskInput {
status?: DevPlanTaskStatusDto;
attachmentUrls?: string[];
}
@@ -228,7 +234,23 @@ export interface DevPlanLinkedTaskSummary {
/** 技术支持工单类型 → 开发任务类型 */
export type DevPlanTaskExportFormat = 'markdown' | 'docx' | 'xlsx' | 'pdf';
export interface DevPlanTaskExportRequest {
scope: 'filter' | 'selected';
format: DevPlanTaskExportFormat;
ids?: string[];
status?: string;
type?: string;
keyword?: string;
}
export interface DevPlanTaskExportResponse {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
}
export function mapSupportTicketTypeToDevPlanTask(
+15 -2
View File
@@ -10,7 +10,9 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'partners', label: '开城管理', group: '业务' },
{ key: 'finance', label: '财务', group: '业务' },
{ key: 'benefit', label: '好客权益', group: '业务' },
{ key: 'benefit_debug', label: '核销调试', group: '业务' },
{ key: 'deliveries', label: '配送单', group: '业务' },
{ key: 'deliveries_debug', label: '小飞侠联调', group: '业务' },
{ key: 'tickets', label: '工单中心', group: '业务' },
{ key: 'tech_support', label: '技术支持', group: '业务' },
{ key: 'invoices', label: '发票管理', group: '业务' },
@@ -41,6 +43,11 @@ export const HQ_PERMISSION_CATALOG = [
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
/** 危险操作:非超管角色默认不含,需在权限分配中显式勾选;超管默认拥有 */
export const HQ_DEBUG_PERMISSION_KEYS = [
'benefit_debug',
'deliveries_debug',
] as const satisfies readonly HqPermissionKey[];
export const HQ_DANGEROUS_PERMISSION_KEYS = [
'users_delete',
'orders_delete',
@@ -51,8 +58,14 @@ export function isHqDangerousPermission(key: string): boolean {
return (HQ_DANGEROUS_PERMISSION_KEYS as readonly string[]).includes(key);
}
export function isHqDebugPermission(key: string): boolean {
return (HQ_DEBUG_PERMISSION_KEYS as readonly string[]).includes(key);
}
export function hqBasePermissionKeys(): HqPermissionKey[] {
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter((k) => !isHqDangerousPermission(k));
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter(
(k) => !isHqDangerousPermission(k) && !isHqDebugPermission(k),
);
}
/** 系统配置 registry group → 权限 key */
@@ -100,7 +113,7 @@ export const HQ_ADMIN_ROLES = [
] as const;
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
SUPER_ADMIN: hqBasePermissionKeys(),
SUPER_ADMIN: [...hqBasePermissionKeys(), ...HQ_DEBUG_PERMISSION_KEYS],
OPS: [
'dashboard',
'users',
+9
View File
@@ -1,5 +1,7 @@
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
/** C 端开票品类:酒水类 / 餐饮类(票种固定增值税普通发票) */
export type InvoiceCategory = 'LIQUOR' | 'CATERING';
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
@@ -12,6 +14,11 @@ export const INVOICE_KIND_LABELS: Record<InvoiceKind, string> = {
SPECIAL: '增值税专用发票',
};
export const INVOICE_CATEGORY_LABELS: Record<InvoiceCategory, string> = {
LIQUOR: '酒水类',
CATERING: '餐饮类',
};
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
PENDING: '待开票',
ISSUED: '已开票',
@@ -21,6 +28,7 @@ export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
export interface CreateInvoiceRequest {
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
taxNo?: string;
addressPhone?: string;
@@ -37,6 +45,7 @@ export interface InvoiceDto {
userId: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
taxNo?: string | null;
addressPhone?: string | null;
@@ -32,6 +32,34 @@ export const STORE_INFO_CHANGEABLE_FIELDS = [
export type StoreInfoChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
/** 可变字段中文名(企微推送 / HQ 审核列表共用) */
export const STORE_INFO_CHANGEABLE_FIELD_LABELS: Record<StoreInfoChangeableField, string> = {
name: '门店名称',
contactPhone: '联系电话',
address: '详细地址',
intro: '门店简介',
benefitUsageRule: '权益券使用规则',
latitude: '纬度',
longitude: '经度',
openTime: '营业开始',
closeTime: '营业结束',
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
coverUrl: '门头照',
envPhotoUrls: '环境照片',
};
/** 将字段 key 列表格式化为中文,如「门店名称、详细地址」 */
export function formatStoreInfoChangeFieldLabels(
fields: readonly string[],
separator = '、',
): string {
return fields
.map((f) => STORE_INFO_CHANGEABLE_FIELD_LABELS[f as StoreInfoChangeableField] ?? f)
.join(separator);
}
/** 单条记录的字段详情(用于审核页 diff 对比) */
export interface StoreInfoChangeFieldDiff {
field: StoreInfoChangeableField;
@@ -96,6 +96,7 @@ export interface RejectSupportTicketRequest {
export interface CreateDevPlanTaskFromTicketInput {
content: string;
type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
attachmentUrls?: string[];
}
export interface ReviewSupportTicketRequest {
+24
View File
@@ -114,6 +114,22 @@ export type PartnerProxyOrderProductOption = {
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
specEnabled?: boolean;
saleUnit?: 'BOTTLE' | 'BOX';
defaultSkuId?: string;
skus?: Array<{
id: string;
specText: string;
price: number;
benefitAmount: number;
status: string;
allowOnSitePickup: boolean;
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
saleUnit: 'BOTTLE' | 'BOX';
bottlesPerUnit: number;
isDefault: boolean;
}>;
};
export type PartnerProxyOrderPromoOption = {
@@ -145,6 +161,7 @@ export type PartnerProxyOrderPreviewRequest = {
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
skuId?: string;
};
export type PartnerProxyOrderPreviewResult = {
@@ -153,6 +170,11 @@ export type PartnerProxyOrderPreviewResult = {
benefitAmount: number;
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
unitPrice: number;
skuId?: string;
saleUnit?: 'BOTTLE' | 'BOX';
bottlesPerUnit?: number;
bottleQuantity?: number;
minQuantity?: number;
};
export type PartnerProxyOrderCreateRequest = {
@@ -169,6 +191,7 @@ export type PartnerProxyOrderCreateRequest = {
productId: string;
quantity: number;
promoCodeId?: string;
skuId?: string;
};
/** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */
@@ -203,4 +226,5 @@ export type HqProxyOrderCreateRequest = {
productId: string;
quantity: number;
promoCodeId?: string;
skuId?: string;
};
+130 -7
View File
@@ -1,4 +1,4 @@
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 门店审核 */
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知 */
export const WECOM_PUSH_CONDITIONS = [
'alert.ops',
'support_ticket.created',
@@ -9,6 +9,11 @@ export const WECOM_PUSH_CONDITIONS = [
'dev_plan.task_dispatch',
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'order.paid',
'redeem.success',
'invoice.pending',
] as const;
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
@@ -23,6 +28,11 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
'dev_plan.task_dispatch': '开发任务评审派发',
'store.audit_pending': '门店提交/重提待审',
'store.package_audit_pending': '套餐变更待审',
'store.info_change_pending': '门店信息变更待审',
'store.withdraw_pending': '门店手动提现待审',
'order.paid': '订单支付成功',
'redeem.success': '门店核销成功',
'invoice.pending': '发票申请待开票',
};
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
@@ -37,9 +47,25 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
},
{
key: 'pay_redeem',
label: '支付与核销',
label: '支付与核销异常',
conditions: ['alert.pay', 'alert.redeem'],
},
{
key: 'deal_broadcast',
label: '成交播报',
conditions: ['order.paid', 'redeem.success'],
},
{
key: 'biz_todo',
label: '业务待办',
conditions: [
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
],
},
{
key: 'system',
label: '系统与结算',
@@ -50,11 +76,6 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
label: '开发计划',
conditions: ['dev_plan.task_dispatch'],
},
{
key: 'store_audit',
label: '门店审核',
conditions: ['store.audit_pending', 'store.package_audit_pending'],
},
];
/** 默认「运营告警」推送条件 */
@@ -79,12 +100,114 @@ export const WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS: WecomPushCondition[] = [
'store.package_audit_pending',
];
/** 默认「业务待办通知群」 */
export const WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS: WecomPushCondition[] = [
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
];
/** 默认「成交播报群」 */
export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[] = [
'order.paid',
'redeem.success',
];
export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群';
export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群';
export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群';
/** 占位 webhook(未配置真实 key 时禁用,避免误推) */
export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK =
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=PENDING';
/** 有 HQ 可编辑模板的业务事件(含入驻/套餐,不含 alert.*) */
export const WECOM_TEMPLATE_EVENT_KEYS = [
'order.paid',
'redeem.success',
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
] as const;
export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number];
export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string> = {
'order.paid': '订单支付成功',
'redeem.success': '门店核销成功',
'store.audit_pending': '门店提交/重提待审',
'store.package_audit_pending': '套餐变更待审',
'store.info_change_pending': '门店信息变更待审',
'store.withdraw_pending': '门店手动提现待审',
'invoice.pending': '发票申请待开票',
};
/** 各事件可用占位符说明(HQ 模板编辑) */
export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = {
'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'],
'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'],
'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'],
'store.package_audit_pending': [
'storeName',
'cityName',
'submitter',
'packageCount',
'time',
'handleUrl',
'handleLabel',
'requestId',
],
'store.info_change_pending': [
'storeName',
'cityName',
'submitter',
'changedFields',
'time',
'handleUrl',
'handleLabel',
'requestId',
],
'store.withdraw_pending': [
'storeName',
'withdrawNo',
'amount',
'payoutCount',
'time',
'handleUrl',
'handleLabel',
'storeId',
],
'invoice.pending': [
'invoiceNo',
'orderNo',
'payAmount',
'titleName',
'phoneMasked',
'time',
'handleUrl',
'handleLabel',
],
};
export type WecomPushTemplateDto = {
id: string;
eventKey: WecomTemplateEventKey;
title: string;
body: string;
handleLabel: string;
updatedAt: string;
};
export type UpdateWecomPushTemplateRequest = {
title?: string;
body?: string;
handleLabel?: string;
};
export function parseWecomPushConditions(
raw?: string | string[] | null,
): WecomPushCondition[] {
+690 -102
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

+6
View File
@@ -69,6 +69,12 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=local
# HQ 企微业务通知「去处理」快链根地址(无尾斜杠;未配时按 WECOM_ALERT_ENV_LABEL 回退生产/测试/本机)
# HQ_ADMIN_PUBLIC_URL=http://localhost:5175
# 生产请设:https://admin.dukanghaoke.com ;测试:https://admin-test.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
@@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=production
# HQ 企微业务通知「去处理」快链根地址
HQ_ADMIN_PUBLIC_URL=https://admin.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
+5
View File
@@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=staging
# HQ 企微业务通知「去处理」快链根地址
HQ_ADMIN_PUBLIC_URL=https://admin-test.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
+12
View File
@@ -0,0 +1,12 @@
# PDF 导出字体
订单 PDF 导出需要支持中文的 TTF/OTF/TTC 字体。按优先级尝试:
1. 环境变量 `EXPORT_PDF_FONT_PATH`
2. `assets/fonts/NotoSansSC-Regular.otf`(推荐,SIL OFL
3. `assets/fonts/simhei.ttf`
4. `assets/fonts/msyh.ttc`
5. Linux 系统字体(如 `fonts-noto-cjk`
6. Windows 系统字体(开发机)
本地开发可将 `C:\Windows\Fonts\simhei.ttf` 复制到本目录。
+2 -1
View File
@@ -3,6 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [{ "include": "../assets/**/*", "outDir": "dist", "watchAssets": true }]
}
}
+8 -1
View File
@@ -21,7 +21,10 @@
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts",
"inventory:test-data": "ts-node --transpile-only scripts/inventory-test-data.ts",
"cleanup:test-data": "ts-node --transpile-only scripts/cleanup-prod-test-data.ts"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.6.0",
@@ -42,10 +45,13 @@
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"docx": "^9.5.0",
"dotenv": "^17.4.2",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"ioredis": "^5.4.1",
"ip2region": "^2.3.0",
"pdfkit": "^0.19.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
@@ -57,6 +63,7 @@
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"@types/pdfkit": "^0.17.6",
"@types/qrcode": "^1.5.6",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
@@ -0,0 +1,77 @@
/**
* v3.5.4 SKU SKU migrate-product-sku-v354.sql
* cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const PREFIX = 'DK';
const PAD = 6;
const RE = /^DK(\d+)$/;
async function nextDkCode() {
const [items, skus] = await Promise.all([
prisma.commonProductItem.findMany({
where: { skuCode: { startsWith: PREFIX } },
select: { skuCode: true },
}),
prisma.commonProductSku.findMany({
where: { skuCode: { startsWith: PREFIX } },
select: { skuCode: true },
}),
]);
let maxSeq = 0;
for (const row of [...items, ...skus]) {
const m = RE.exec(row.skuCode);
if (!m) continue;
const n = Number(m[1]);
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
}
return `${PREFIX}${String(maxSeq + 1).padStart(PAD, '0')}`;
}
async function main() {
const products = await prisma.commonProductItem.findMany({
include: { skus: { select: { id: true }, take: 1 } },
});
let created = 0;
for (const p of products) {
if (p.skus.length > 0) continue;
const skuCode = RE.test(p.skuCode) ? p.skuCode : await nextDkCode();
await prisma.commonProductSku.create({
data: {
productId: p.id,
skuCode,
barcode69: p.barcode69,
specKey: '',
specText: p.spec,
price: p.price,
benefitAmount: p.benefitAmount,
status: p.status,
allowOnSitePickup: p.allowOnSitePickup,
allowOnlinePurchase: p.allowOnlinePurchase,
allowCrossCityDelivery: p.allowCrossCityDelivery,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
sortOrder: 0,
},
});
if (p.skuCode !== skuCode) {
await prisma.commonProductItem.update({
where: { id: p.id },
data: { skuCode },
});
}
created += 1;
console.log(`created default sku for product ${p.id} ${skuCode}`);
}
console.log(`done, created=${created}, scanned=${products.length}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,352 @@
/**
* A DK000007 / DK000008 SPU SKU id DK SKU
*
* cd server/dukang-api && npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts
* npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts --dry-run
*
* SPU = DK000007 = 20
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const CODES = ['DK000007', 'DK000008'] as const;
const SURVIVOR_CODE = 'DK000007';
const SPEC_ATTR_NAME = '规格';
const SPU_NAME = '酒祖杜康(国标特级)';
const SPEC_LABEL: Record<(typeof CODES)[number], string> = {
DK000007: '国标特级20',
DK000008: '国标特级30',
};
const dryRun = process.argv.includes('--dry-run');
function buildSpecKey(valueIds: bigint[]): string {
if (!valueIds.length) return '';
return [...valueIds]
.map((id) => id.toString())
.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0))
.join('_');
}
async function ensureSkuImageColumn() {
const rows = await prisma.$queryRaw<Array<{ cnt: bigint | number }>>`
SELECT COUNT(*) AS cnt
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'common_product_sku'
AND COLUMN_NAME = 'image_url'
`;
const cnt = Number(rows[0]?.cnt ?? 0);
if (cnt > 0) return;
await prisma.$executeRawUnsafe(
'ALTER TABLE `common_product_sku` ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`',
);
console.log('added column common_product_sku.image_url');
}
async function resolveProductByCode(skuCode: string) {
const sku = await prisma.commonProductSku.findUnique({
where: { skuCode },
include: { product: true },
});
if (sku) return { product: sku.product, sku };
const product = await prisma.commonProductItem.findFirst({
where: { skuCode },
});
if (!product) {
throw new Error(`找不到商品/SKU${skuCode}`);
}
return { product, sku: null as null };
}
async function coverUrlOf(productId: bigint, coverResourceId: bigint | null) {
if (coverResourceId) {
const res = await prisma.commonResource.findUnique({
where: { id: coverResourceId },
select: { url: true },
});
if (res?.url) return res.url;
}
const fallback = await prisma.commonResource.findFirst({
where: { ownerType: 'PRODUCT', ownerId: productId, bizType: 'COVER', status: 'ACTIVE' },
orderBy: { id: 'asc' },
select: { url: true },
});
return fallback?.url ?? null;
}
async function ensureSkuFromProduct(
tx: PrismaClient,
product: {
id: bigint;
skuCode: string;
barcode69: string;
spec: string;
price: unknown;
benefitAmount: unknown;
status: 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
allowOnSitePickup: boolean;
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
},
) {
const existing =
(await tx.commonProductSku.findUnique({ where: { skuCode: product.skuCode } })) ??
(await tx.commonProductSku.findFirst({
where: { productId: product.id, isDefault: true },
}));
if (existing) return existing;
return tx.commonProductSku.create({
data: {
productId: product.id,
skuCode: product.skuCode,
barcode69: product.barcode69,
specKey: '',
specText: product.spec,
price: product.price as never,
benefitAmount: product.benefitAmount as never,
status: product.status,
allowOnSitePickup: product.allowOnSitePickup,
allowOnlinePurchase: product.allowOnlinePurchase,
allowCrossCityDelivery: product.allowCrossCityDelivery,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
sortOrder: 0,
},
});
}
async function main() {
await ensureSkuImageColumn();
const rows = [];
for (const code of CODES) {
const resolved = await resolveProductByCode(code);
rows.push({ code, ...resolved });
}
const survivorRow = rows.find((r) => r.code === SURVIVOR_CODE);
if (!survivorRow) throw new Error(`缺少幸存码 ${SURVIVOR_CODE}`);
const discardRows = rows.filter((r) => r.product.id !== survivorRow.product.id);
const survivorId = survivorRow.product.id;
console.log(
JSON.stringify(
{
dryRun,
survivor: {
productId: survivorId.toString(),
name: survivorRow.product.name,
skuCode: survivorRow.product.skuCode,
},
items: await Promise.all(
rows.map(async (r) => {
const orderCount = await prisma.order.count({ where: { productId: r.product.id } });
const nullSku = await prisma.order.count({
where: { productId: r.product.id, skuId: null },
});
return {
code: r.code,
productId: r.product.id.toString(),
name: r.product.name,
skuId: r.sku?.id.toString() ?? null,
orders: orderCount,
nullSkuOrders: nullSku,
};
}),
),
},
null,
2,
),
);
if (discardRows.length === 0 && survivorRow.sku) {
const sibling = await prisma.commonProductSku.findMany({
where: { productId: survivorId, skuCode: { in: [...CODES] } },
});
if (sibling.length === CODES.length) {
console.log('SKU 已在同一 SPU 上,继续核对订单 product_id / sku_id');
}
}
if (dryRun) {
console.log('[dry-run] skip writes');
return;
}
await prisma.$transaction(
async (tx) => {
const db = tx as unknown as PrismaClient;
const skuByCode = new Map<string, { id: bigint }>();
for (const row of rows) {
const sku = await ensureSkuFromProduct(db, row.product);
skuByCode.set(row.code, sku);
console.log(`ensured sku ${row.code} id=${sku.id} fromProduct=${row.product.id}`);
}
let attr = await db.commonProductSpecAttr.findFirst({
where: { productId: survivorId, name: SPEC_ATTR_NAME },
});
if (!attr) {
attr = await db.commonProductSpecAttr.create({
data: { productId: survivorId, name: SPEC_ATTR_NAME, sortOrder: 0 },
});
}
const valueIdByCode = new Map<string, bigint>();
for (let i = 0; i < CODES.length; i++) {
const code = CODES[i];
const label = SPEC_LABEL[code];
let value = await db.commonProductSpecValue.findFirst({
where: { attrId: attr.id, name: label },
});
if (!value) {
value = await db.commonProductSpecValue.create({
data: { attrId: attr.id, name: label, sortOrder: i },
});
}
valueIdByCode.set(code, value.id);
}
for (let i = 0; i < CODES.length; i++) {
const code = CODES[i];
const sku = skuByCode.get(code)!;
const valueId = valueIdByCode.get(code)!;
const specKey = buildSpecKey([valueId]);
const specText = SPEC_LABEL[code];
const sourceProduct = rows.find((r) => r.code === code)!.product;
const imageUrl = await coverUrlOf(sourceProduct.id, sourceProduct.coverResourceId);
const isDefault = code === SURVIVOR_CODE;
await db.commonProductSkuSpec.deleteMany({ where: { skuId: sku.id } });
await db.commonProductSku.update({
where: { id: sku.id },
data: {
product: { connect: { id: survivorId } },
specKey,
specText,
isDefault,
sortOrder: i,
imageUrl,
status: sourceProduct.status,
},
});
await db.commonProductSkuSpec.create({
data: { skuId: sku.id, valueId },
});
const orderRes = await db.order.updateMany({
where: {
OR: [{ skuId: sku.id }, { productId: sourceProduct.id, skuId: null }],
},
data: { productId: survivorId, skuId: sku.id },
});
console.log(
`bound ${code} → product ${survivorId} sku ${sku.id} spec=${specText} orders=${orderRes.count}`,
);
}
const leftover = await db.order.findMany({
where: { productId: { in: discardRows.map((r) => r.product.id) } },
select: { id: true, orderNo: true, productId: true, skuId: true },
});
if (leftover.length) {
throw new Error(
`仍有订单绑在被合并 SPU 上:${leftover.map((o) => o.orderNo).join(', ')}`,
);
}
for (const discard of discardRows) {
const phones = await db.commonProductVisibilityPhone.findMany({
where: { productId: discard.product.id },
});
for (const p of phones) {
await db.commonProductVisibilityPhone.upsert({
where: { productId_phone: { productId: survivorId, phone: p.phone } },
create: { productId: survivorId, phone: p.phone },
update: {},
});
}
await db.commonProductVisibilityPhone.deleteMany({
where: { productId: discard.product.id },
});
await db.commonProductSkuSpec.deleteMany({
where: { sku: { productId: discard.product.id } },
});
await db.commonProductSku.deleteMany({ where: { productId: discard.product.id } });
await db.commonProductSpecAttr.deleteMany({ where: { productId: discard.product.id } });
await db.commonResource.deleteMany({
where: { ownerType: 'PRODUCT', ownerId: discard.product.id },
});
await db.commonProductItem.update({
where: { id: discard.product.id },
data: { status: 'OFF_SALE', coverResourceId: null },
});
await db.commonProductItem.delete({ where: { id: discard.product.id } });
console.log(`deleted discard SPU ${discard.product.id} ${discard.code}`);
}
const defaultSku = await db.commonProductSku.findUniqueOrThrow({
where: { skuCode: SURVIVOR_CODE },
});
await db.commonProductItem.update({
where: { id: survivorId },
data: {
name: SPU_NAME,
skuCode: defaultSku.skuCode,
barcode69: defaultSku.barcode69,
spec: defaultSku.specText,
price: defaultSku.price,
benefitAmount: defaultSku.benefitAmount,
allowOnSitePickup: defaultSku.allowOnSitePickup,
allowOnlinePurchase: defaultSku.allowOnlinePurchase,
allowCrossCityDelivery: defaultSku.allowCrossCityDelivery,
status: 'ON_SALE',
},
});
},
{ timeout: 30000 },
);
const skus = await prisma.commonProductSku.findMany({
where: { skuCode: { in: [...CODES] } },
orderBy: { sortOrder: 'asc' },
});
const orders = await prisma.order.groupBy({
by: ['productId', 'skuId'],
where: { sku: { skuCode: { in: [...CODES] } } },
_count: true,
});
console.log(
JSON.stringify(
{
done: true,
skus: skus.map((s) => ({
id: s.id.toString(),
productId: s.productId.toString(),
skuCode: s.skuCode,
specText: s.specText,
isDefault: s.isDefault,
})),
orders: orders.map((o) => ({
productId: o.productId.toString(),
skuId: o.skuId?.toString() ?? null,
count: o._count,
})),
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,2 @@
-- v3.5.6: 开发计划任务附件
ALTER TABLE `dev_plan_task` ADD COLUMN `attachment_urls` JSON NULL AFTER `support_ticket_id`;
@@ -0,0 +1,5 @@
-- v3.5.6: 财务账单打款凭证
ALTER TABLE `store_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `partner_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `winery_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `logistics_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
@@ -0,0 +1,5 @@
-- 发票开票品类:酒水类 / 餐饮类(票种仍为增值税普通发票)
-- 存量申请默认酒水类,与 Prisma InvoiceCategory 默认值一致
ALTER TABLE `user_invoice`
ADD COLUMN `invoice_category` VARCHAR(16) NOT NULL DEFAULT 'LIQUOR' AFTER `invoice_kind`;

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