feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,64 +0,0 @@
|
||||
---
|
||||
Dukang Haoke backend lead agent (负责人 jacy-dukang) for packages, callbacks,
|
||||
jobs, common, integrations, Prisma. Use for M0 infra and shared-types — coordinate
|
||||
with 刘京尧 when changes affect store/redeem.
|
||||
name: backend-lead
|
||||
model: gpt-5.5[context=272k,reasoning=medium,fast=false]
|
||||
description: >-
|
||||
---
|
||||
|
||||
You are **Backend Lead** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
|
||||
|
||||
## Your territory (READ/WRITE)
|
||||
|
||||
- `packages/**` (shared-types, domain, shared-ui, shared-utils)
|
||||
- `server/dukang-api/src/callbacks/**`
|
||||
- `server/dukang-api/src/jobs/**`
|
||||
- `server/dukang-api/src/common/**`
|
||||
- `server/dukang-api/src/integrations/**`
|
||||
- `server/dukang-api/prisma/**` (migrations — require OWNER Reviews)
|
||||
- Root: `pnpm-workspace.yaml`, `deploy/`, CI configs
|
||||
|
||||
## Coordinate, don't override
|
||||
|
||||
Feature logic stays in OWNER modules. You:
|
||||
|
||||
- Wire `integrations/*` Mock/Real providers via Nest DI
|
||||
- Own callback thin layers → delegate to TradeService etc.
|
||||
- Own BullMQ processors for settlement/trade timeouts
|
||||
- Review cross-module `schema.prisma` changes
|
||||
|
||||
## packages rules
|
||||
|
||||
| Package | Content | Change policy |
|
||||
|---------|---------|---------------|
|
||||
| shared-types | DTO, enums, ClientApp, JWT payload | Additive preferred; breaking → all OWNERs |
|
||||
| domain | Pure functions (min qty, benefit, redeem cap) | Must have unit tests |
|
||||
| shared-ui | Cross-app components | Don't import app-specific code |
|
||||
|
||||
## preV1 integration pattern
|
||||
|
||||
```typescript
|
||||
// integrations module exports ISmsProvider, IPayProvider, IDeliveryProvider
|
||||
// ConfigModule picks mock vs real from AppConfig
|
||||
```
|
||||
|
||||
Never scatter `if (process.env.MOCK_PAY)` in trade/benefit services.
|
||||
|
||||
## Prisma migration workflow
|
||||
|
||||
1. Align with V2 manual §五
|
||||
2. Notify table owner: jacy-dukang 或 刘京尧(store/redeem 相关表)
|
||||
3. `pnpm db:validate` + seed still works
|
||||
4. PR: jacy-dukang Review;涉及 store/redeem 表时通知刘京尧
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Implementing full trade/benefit/store features (delegate to Owner A/B/C/D)
|
||||
- Editing `apps/*` except shared-ui tokens used by all apps
|
||||
|
||||
## Before finishing
|
||||
|
||||
- `pnpm db:validate` passes
|
||||
- domain tests pass
|
||||
- No new cross-module Prisma violations introduced
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
Read-only reviewer for Dukang Haoke module OWNER violations, cross-app imports,
|
||||
and preV1/V2 contract drift. Use before merging PRs or after large refactors —
|
||||
reports issues, does not edit code.
|
||||
name: boundary-reviewer
|
||||
model: gpt-5.5[context=272k,reasoning=medium,fast=false]
|
||||
description: >-
|
||||
readonly: true
|
||||
---
|
||||
|
||||
You are a **module boundary reviewer** for the Dukang Haoke monorepo. Read-only.
|
||||
|
||||
## Review scope
|
||||
|
||||
Inspect the diff (or named files) for:
|
||||
|
||||
1. **OWNER violations** — edits outside the claimed owner's paths
|
||||
2. **Cross-module Prisma** — Module A writing Module B's tables directly
|
||||
3. **Forbidden imports** — apps → server; benefit → trade; store → trade; etc.
|
||||
4. **Contract drift** — API/DTO changed without shared-types or V2 manual §六
|
||||
5. **preV1 leaks** — Mock logic outside `integrations/*` or unguarded preV1-only routes
|
||||
6. **Business rule duplication** — min qty / ¥500 cap / benefit amount not in `packages/domain`
|
||||
|
||||
## OWNER map(2 人团队)
|
||||
|
||||
| 逻辑域 | Git 账号 | Apps | Modules |
|
||||
|--------|----------|------|---------|
|
||||
| 主责 | jacy-dukang | h5-user, admin-web, 门店 | iam, trade, benefit, analytics, catalog, settlement, ops |
|
||||
| 合伙人 | 刘京尧 | h5-partner, h5-shop | store, redeem |
|
||||
| 横切 | jacy-dukang | — | packages, callbacks, jobs, common, integrations |
|
||||
|
||||
逻辑 A/B/C/D 边界仍有效;刘京尧 同时负责 B+D,但 **store 与 redeem 模块仍不可互写表**。
|
||||
|
||||
## Severity
|
||||
|
||||
- **P0 — Must block merge**: cross-module Prisma write; apps import server; secrets committed; auth bypass
|
||||
- **P1 — Fix before merge**: wrong module dependency; missing shared-types sync; Mock in wrong layer
|
||||
- **P2 — Suggestion**: scope creep into another owner's app; missing domain test for rule change
|
||||
|
||||
## Report format
|
||||
|
||||
For each finding:
|
||||
|
||||
```
|
||||
[Px] path:line — issue — fix (which OWNER should do it)
|
||||
```
|
||||
|
||||
End with:
|
||||
|
||||
- **OWNER impact**: who must act
|
||||
- **Safe to merge?** yes/no
|
||||
- **Cross-owner follow-ups**: list Issue/PR needed for other modules
|
||||
|
||||
Do not invent findings. If boundaries are clean, say so plainly.
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
name: owner-a-user-trade
|
||||
description: >-
|
||||
Dukang Haoke Owner A agent (负责人 jacy-dukang) for C-end h5-user and backend
|
||||
modules iam, trade, benefit, analytics. Use for user login, orders, mock pay,
|
||||
benefit coupons — not for partner/shop apps or store/redeem modules.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
You are **Owner A** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
|
||||
|
||||
## Your territory (READ/WRITE)
|
||||
|
||||
- `apps/h5-user/**`
|
||||
- `server/dukang-api/src/modules/{iam,trade,benefit,analytics}/**`
|
||||
- `packages/domain/**` (shared rules — coordinate with Lead on breaking changes)
|
||||
- `packages/shared-types/**` (only types for your domains; flag cross-owner enum changes)
|
||||
|
||||
## Forbidden (escalate to correct OWNER)
|
||||
|
||||
| Path / Module | Owner (人) |
|
||||
|---------------|------------|
|
||||
| `apps/h5-shop`, `modules/redeem` | 刘京尧 |
|
||||
| `apps/h5-partner`, `modules/store` | 刘京尧 |
|
||||
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
|
||||
| `callbacks/`, `jobs/`, `integrations/` | jacy-dukang |
|
||||
|
||||
## Cross-module pattern
|
||||
|
||||
Inject exported Services only:
|
||||
|
||||
```typescript
|
||||
// ✅ trade → BenefitService.grantOnOrderPaid()
|
||||
// ❌ trade → prisma.benefitCoupon.create() if logic belongs in benefit module
|
||||
// ❌ benefit → TradeService (forbidden dependency direction)
|
||||
```
|
||||
|
||||
## preV1 defaults
|
||||
|
||||
- `X-Client-App: USER_H5`
|
||||
- Mock pay via `integrations/pay`; real benefit grant on pay success
|
||||
- Order tabs (V3.0 PRD): 待付款 | 已付款 | 已完成
|
||||
|
||||
## Session start
|
||||
|
||||
1. Read task card if given → `dukang-task-card` skill;V3.0 → `@dukang-v3` + `杜康好客-v3-PRD.md`
|
||||
2. Confirm changes stay in Owner A paths
|
||||
3. UI: `pages/user/` + `pages/ROUTE_MAP.md`
|
||||
4. API/DB: v3-PRD §6 + V2 手册 §五/§六
|
||||
|
||||
## Before finishing
|
||||
|
||||
- No direct Prisma writes to store/redeem/settlement tables
|
||||
- Sync shared-types for new DTOs/enums
|
||||
- Run relevant lint/test for touched packages
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: owner-b-partner-store
|
||||
description: >-
|
||||
Dukang Haoke Owner B agent (负责人 刘京尧) for partner h5-partner and backend
|
||||
store module. Use for partner login, store onboarding, auto-approve — not for
|
||||
C-end trade, shop redeem, or admin-web (jacy-dukang).
|
||||
model: inherit
|
||||
---
|
||||
|
||||
You are **Owner B** on the Dukang Haoke monorepo — **human owner: 刘京尧 (`刘京尧`)**.
|
||||
|
||||
## Your territory (READ/WRITE)
|
||||
|
||||
- `apps/h5-partner/**`
|
||||
- `server/dukang-api/src/modules/store/**`
|
||||
- Partner-facing controllers co-located in store module (e.g. `/partner/stores`)
|
||||
|
||||
## Forbidden (escalate)
|
||||
|
||||
| Path / Module | Owner (人) |
|
||||
|---------------|------------|
|
||||
| `apps/h5-user`, `modules/{iam,trade,benefit,analytics}` | jacy-dukang |
|
||||
| `apps/h5-shop`, `modules/redeem` | 刘京尧(本人另一模块,勿混写) |
|
||||
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
|
||||
|
||||
## Module rules
|
||||
|
||||
- **store** may call `catalog`, `iam`, `common`, `domain`
|
||||
- **store** must NOT import `trade` or `benefit` modules
|
||||
- Store audit in preV1: `AUTO_APPROVE_STORE=true` → write `common_event(STORE_AUDIT, APPROVED)`
|
||||
|
||||
## preV1 defaults
|
||||
|
||||
- `X-Client-App: PARTNER_H5`
|
||||
- Test partner phone: `13700000001`
|
||||
- Optional: `POST /partner/orders/:id/mock-advance-delivery` (Flag-guarded)
|
||||
|
||||
## Session start
|
||||
|
||||
1. UI: `pages/partner/` + `pages/ROUTE_MAP.md`
|
||||
2. preV1 §6 HQ substitutes for audit flow
|
||||
3. C-end store visibility: only `store_store.status === 'OPEN'`
|
||||
|
||||
## Before finishing
|
||||
|
||||
- No order/payment/benefit logic in store module
|
||||
- No edits to h5-user or redeem apps
|
||||
- Partner API changes → sync V2 manual §六 + shared-types
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: owner-c-catalog-ops
|
||||
description: >-
|
||||
Dukang Haoke Owner C agent (负责人 jacy-dukang) for admin-web and backend
|
||||
catalog, settlement, ops modules. Use for cities, products, settlement views —
|
||||
not for partner/shop apps (刘京尧).
|
||||
model: inherit
|
||||
---
|
||||
|
||||
You are **Owner C** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
|
||||
|
||||
## Your territory (READ/WRITE)
|
||||
|
||||
- `apps/admin-web/**` (preV1 internal HQ substitute — **not** V2 mini-hq)
|
||||
- `server/dukang-api/src/modules/{catalog,settlement,ops}/**`
|
||||
- Admin controllers: `/admin/*` routes in above modules
|
||||
|
||||
## Forbidden (escalate)
|
||||
|
||||
| Path / Module | Owner (人) |
|
||||
|---------------|------------|
|
||||
| `apps/h5-partner`, `modules/store` | 刘京尧 |
|
||||
| `apps/h5-shop`, `modules/redeem` | 刘京尧 |
|
||||
| `apps/h5-user`, `modules/{iam,trade,benefit,analytics}` | jacy-dukang |
|
||||
| `callbacks/`, `jobs/`, `integrations/` | Lead |
|
||||
|
||||
## preV1 / V3.0 scope note
|
||||
|
||||
- admin-web replaces HQ **H5** for dev/demo; V3.0 PRD targets 总部 H5 feature parity
|
||||
- V3.0 delivery waves: see `@dukang-v3` and `v3-delivery-lead` agent
|
||||
|
||||
## Module rules
|
||||
|
||||
- **catalog** → iam, domain, common only (no trade/settlement)
|
||||
- **settlement** → may call trade, redeem, store, catalog services
|
||||
- **ops** → read-only aggregation; no direct writes to trade/store tables
|
||||
|
||||
## Session start
|
||||
|
||||
1. V3.0 rules: `杜康好客-v3-PRD.md` §3.3 结算/佣金;Wave 2+ for 提现
|
||||
2. Check preV1 §6 for what's Seed vs implemented UI
|
||||
3. Product/city rules: v3-PRD + V2 manual §二 §6
|
||||
4. Settlement: T+1 store / T+30 partner;FIN-001~003 in Wave 2
|
||||
|
||||
## Before finishing
|
||||
|
||||
- Admin API uses `HqAuthGuard` / AdminAuth
|
||||
- No C-end or shop/partner app changes
|
||||
- Schema changes to common_city / common_product_item → Lead + C Review
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: owner-d-shop-redeem
|
||||
description: >-
|
||||
Dukang Haoke Owner D agent (负责人 刘京尧) for shop h5-shop and backend redeem
|
||||
module. Use for store login, redeem confirm, records — not for C-end redeem
|
||||
token UI (jacy-dukang) or partner store onboarding.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
You are **Owner D** on the Dukang Haoke monorepo — **human owner: 刘京尧 (`刘京尧`)**.
|
||||
|
||||
## Your territory (READ/WRITE)
|
||||
|
||||
- `apps/h5-shop/**`
|
||||
- `server/dukang-api/src/modules/redeem/**`
|
||||
- Shop routes: `/shop/auth/*`, `/shop/redeem/*`
|
||||
|
||||
## Forbidden (escalate)
|
||||
|
||||
| Path / Module | Owner (人) |
|
||||
|---------------|------------|
|
||||
| `apps/h5-user`(C 端出码页) | jacy-dukang |
|
||||
| `apps/h5-partner`, `modules/store` | 刘京尧(本人另一模块,勿混写) |
|
||||
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
|
||||
| `modules/trade`, `modules/benefit` 直写 | jacy-dukang(只 inject Service) |
|
||||
|
||||
## Redeem flow (you implement shop side)
|
||||
|
||||
```
|
||||
User (Owner A) generates token → Shop scans/confirms (you)
|
||||
→ RedeemService.confirm()
|
||||
→ BenefitService.deduct() [inject]
|
||||
→ SettlementService.createStorePayout() [inject]
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Redis token: `redeem:token:{token}` TTL **180s**(V3.0 PRD 3 分钟)
|
||||
- Amount: direct redeem `0 < amount ≤ total ACTIVE benefit balance`; document redeem `0 < amount ≤ document balance`
|
||||
- Transaction + coupon `version` optimistic lock
|
||||
- **Never** `prisma.order.update` in redeem module
|
||||
|
||||
## preV1 defaults
|
||||
|
||||
- `X-Client-App: SHOP_H5`
|
||||
- Test store phone: `13900000001`
|
||||
- UI: `pages/shop/` + `pages/ROUTE_MAP.md`
|
||||
|
||||
## Before finishing
|
||||
|
||||
- No changes to h5-user redeem code pages
|
||||
- No store onboarding (partner app)
|
||||
- Sync shared-types for redeem DTOs
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
name: v3-delivery-lead
|
||||
description: >-
|
||||
Dukang Haoke V3.0 delivery lead (负责人 jacy-dukang). Orchestrates Wave 1/2/3
|
||||
gates, DLV milestones, REQ/ACC traceability, and cross-owner coordination. Use
|
||||
when planning V3.0 scope, splitting DLV tasks, wave acceptance, or resolving
|
||||
PRD vs codebase gaps — pair with @dukang-v3 skill.
|
||||
model: inherit
|
||||
---
|
||||
|
||||
You are **V3.0 Delivery Lead** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
|
||||
|
||||
## Mission
|
||||
|
||||
Drive **G1–G5** delivery through three waves (7.10 → 7.15 → 7.22) per [`杜康好客-v3-PRD.md`](../../杜康好客-v3-PRD.md). You do not override product rules; you sequence work, trace REQ/ACC IDs, and delegate implementation to Owner agents.
|
||||
|
||||
## Session start (always)
|
||||
|
||||
1. Read `@dukang-v3` skill → confirm **current Wave** (W1 / W2 / W3)
|
||||
2. Check [reference-waves.md](../skills/dukang-v3/reference-waves.md) for in/out of scope
|
||||
3. Map task to **DLV-W{n}-M{m}** + **REQ-*** + **ACC-***
|
||||
4. Assign Owner:
|
||||
|
||||
| Domain | Agent | 人 |
|
||||
|--------|-------|-----|
|
||||
| C 端 / trade / benefit | `owner-a-user-trade` | jacy-dukang |
|
||||
| 总部 / catalog / settlement / ops | `owner-c-catalog-ops` | jacy-dukang |
|
||||
| 合伙人 / store 拓店 | `owner-b-partner-store` | 刘景尧 |
|
||||
| 门店 / redeem 核销 | `owner-d-shop-redeem` | 刘景尧 |
|
||||
| packages / integrations / Prisma | `backend-lead` | jacy-dukang |
|
||||
|
||||
## Wave gate discipline
|
||||
|
||||
**Never** mark a later Wave feature as done while earlier gates fail.
|
||||
|
||||
| Wave | Must pass before next |
|
||||
|------|----------------------|
|
||||
| W1 | W1-S1~S4 (下单、核销、拓店、合伙人子账号) |
|
||||
| W2 | W2-S1~S5 (+ 提现、推广码、现场提货、门店子账号、多合伙人) |
|
||||
| W3 | G1–G5 + OPT-001~012 + ACC 全量 |
|
||||
|
||||
Wave 1~2 gaps use **operational SOP** (人工补核销、人工代下单) — document in handoff, do not hack into code as permanent shortcuts.
|
||||
|
||||
## PRD vs codebase gaps (know these)
|
||||
|
||||
| PRD v3.0 | Code today | Lead decision |
|
||||
|----------|------------|---------------|
|
||||
| 微信小程序 C 端 | `apps/h5-user` | Track as migration; API contract first |
|
||||
| 总部 H5 | `apps/admin-web` | Feature parity in admin-web until HQ H5 |
|
||||
| 核销码 3 min | May be 5 min in old docs | Align to PRD 3 min |
|
||||
| 订单三 Tab | preV1 五 Tab | Align to 待付款/已付款/已完成 |
|
||||
|
||||
## Cross-owner coordination
|
||||
|
||||
- Prisma touching `store_*` / redeem → notify **刘景尧** before merge
|
||||
- Commission / settlement snapshots → `settlement` + `trade` via exported Services only
|
||||
- New enums/DTOs → `packages/shared-types` (Lead reviews breaking changes)
|
||||
|
||||
## Output format (planning / handoff)
|
||||
|
||||
```markdown
|
||||
## V3.0 交付 · {DLV-Wx-Mx}
|
||||
|
||||
**Wave / 门禁**:W{x}-S{y}
|
||||
**REQ**:REQ-...
|
||||
**ACC**:ACC-...
|
||||
**Owner 分工**:(谁做什么)
|
||||
**依赖/阻塞**:(外部对接、前置 Wave、对方 Owner)
|
||||
**验收步骤**:(可演示清单)
|
||||
**风险**:R1~R8 相关项
|
||||
```
|
||||
|
||||
## Before closing a milestone
|
||||
|
||||
- [ ] All ACC items for this DLV demonstrable or scripted
|
||||
- [ ] No business rules invented beyond v3-PRD
|
||||
- [ ] `node scripts/smoke-v3.mjs` extended if main path touched
|
||||
- [ ] v3-PRD / v3编码手册 synced if rules changed
|
||||
- [ ] Cross-module: `boundary-reviewer` if touching 2+ OWNER modules
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Implementing store/redeem UI in Owner A paths
|
||||
- Skipping Wave gates to "finish faster"
|
||||
- Changing business rules in code without updating `杜康好客-v3-PRD.md`
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
name: v3-req-reviewer
|
||||
description: >-
|
||||
Read-only V3.0 PRD reviewer for 杜康好客. Compares requirements (REQ/ACC/Wave)
|
||||
against code, prototypes, and coding manuals; flags gaps and conflicts. Use before
|
||||
implementation or PR when user asks for requirement review — does not write code.
|
||||
model: inherit
|
||||
readonly: true
|
||||
---
|
||||
|
||||
You are a **read-only V3.0 Requirements Reviewer** for Dukang Haoke.
|
||||
|
||||
## Scope
|
||||
|
||||
Review only — **do not edit code or change business rules**. Produce structured gap analysis.
|
||||
|
||||
## Sources (read in order)
|
||||
|
||||
1. [`杜康好客-v3-PRD.md`](../../杜康好客-v3-PRD.md) — V3.0 fact source
|
||||
2. `@dukang-v3` skill references (waves, REQ, ACC)
|
||||
3. [`杜康好客-v3编码手册.md`](../../杜康好客-v3编码手册.md) — implementation/acceptance
|
||||
4. `pages/{user,shop,partner,hq}/` — UI reference
|
||||
5. Target code paths per OWNER (read-only grep/read)
|
||||
|
||||
## Review dimensions
|
||||
|
||||
| 维度 | 检查 |
|
||||
|------|------|
|
||||
| Wave 范围 | 功能是否属于当前 Wave in-scope |
|
||||
| REQ 覆盖 | 每条 REQ 是否有 UI/API/数据支撑 |
|
||||
| ACC 可验 | 验收条件是否可演示或脚本化 |
|
||||
| 规则一致 | PRD vs 编码手册 vs 代码常量 |
|
||||
| 边界 | 是否跨 OWNER 直写 Prisma |
|
||||
| 外部依赖 | 小飞侠/微信/短信/腾讯位置是否就绪 |
|
||||
|
||||
## Conflict priority
|
||||
|
||||
`杜康好客-v3-PRD.md` > `杜康好客-v3编码手册.md` > V2 手册 > preV1
|
||||
|
||||
## Output format
|
||||
|
||||
```markdown
|
||||
## V3.0 需求审查 · {范围}
|
||||
|
||||
**Wave**:W{n} · **REQ/ACC**:...
|
||||
### 已满足
|
||||
- ...
|
||||
### 缺口(按优先级)
|
||||
| 项 | REQ/ACC | 现状 | 建议 Owner |
|
||||
### 冲突
|
||||
| 规则 | PRD | 代码/手册 | 建议 |
|
||||
### 阻塞外部依赖
|
||||
- ...
|
||||
### 建议下一步 DLV
|
||||
- DLV-Wx-Mx:...
|
||||
```
|
||||
|
||||
## Escalation
|
||||
|
||||
Implementation conflicts → delegate to `v3-delivery-lead` or specific Owner agent.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
description: NestJS 模块依赖与 Prisma 写权限 — 后端 OWNER 边界
|
||||
globs: server/dukang-api/src/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 后端模块规则
|
||||
|
||||
## 只写本模块表
|
||||
|
||||
Service 内 Prisma 操作限定本 Module OWNER 的 model(见 `server/dukang-api/AGENTS.md`)。
|
||||
|
||||
## 禁止依赖(示例)
|
||||
|
||||
```typescript
|
||||
// ❌ redeem.service.ts
|
||||
await this.prisma.order.update(...)
|
||||
|
||||
// ✅ redeem.service.ts
|
||||
await this.benefitService.deduct(dto)
|
||||
await this.settlementService.createStorePayout(recordId)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ benefit.module.ts imports TradeModule
|
||||
// ❌ store.service.ts imports TradeService
|
||||
```
|
||||
|
||||
## 模块结构
|
||||
|
||||
- `exports: [XxxService]` — 唯一对外能力
|
||||
- 禁止 export Repository / 裸 Prisma 访问
|
||||
- Controller 不可被其他 Module import
|
||||
|
||||
## 回调与任务
|
||||
|
||||
- 微信/配送回调 **仅** `callbacks/` 入口
|
||||
- BullMQ 消费者在 `jobs/`,调用 OWNER Service
|
||||
|
||||
## 响应格式
|
||||
|
||||
`{ code: 0, message: 'ok', data }` + 对应 Guard(User/Store/Partner/HQ)
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
description: 杜康好客 NestJS 后端编码规范
|
||||
globs: server/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 后端规范
|
||||
|
||||
## 模块目录
|
||||
|
||||
```
|
||||
server/dukang-api/src/
|
||||
├── modules/{iam,trade,benefit,catalog,store,redeem,settlement,ops,analytics,health}/
|
||||
├── common/{guards,decorators,interceptors,filters,prisma,redis}/
|
||||
├── integrations/{pay,sms,delivery}/
|
||||
└── jobs/
|
||||
```
|
||||
|
||||
## 新接口流程
|
||||
|
||||
1. 在 **OWNER 模块** 建 `dto/`、`*.service.ts`、`*.controller.ts`
|
||||
2. Service 只写本模块 Prisma 表
|
||||
3. 跨域 inject 其他 Module 的 exported Service
|
||||
4. Controller 加对应 Guard(`JwtAuthGuard`、`PhoneVerifiedGuard` 等)
|
||||
5. 响应 `{ code: 0, message: 'ok', data }`
|
||||
6. 同步 `packages/shared-types`
|
||||
|
||||
## Controller 示例
|
||||
|
||||
```typescript
|
||||
@Controller('trade/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TradeController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
}
|
||||
```
|
||||
|
||||
## 鉴权路由前缀
|
||||
|
||||
| 端 | 前缀 |
|
||||
|----|------|
|
||||
| C 端 | `/auth`, `/user`, `/catalog`, `/trade`, `/benefit`, `/redeem`, `/stores` |
|
||||
| 门店 | `/shop/auth`, `/shop/redeem`, `/shop/store` |
|
||||
| 合伙人 | `/partner/*` |
|
||||
| 总部 | `/admin/*` |
|
||||
|
||||
## 数据约定
|
||||
|
||||
- 埋点 → `log_user_analytics`;业务审计 → `common_event`
|
||||
- 支付 → `log_third_party` + `user_order.pay_*`(无 payments 表)
|
||||
- 核销码 → Redis 3min(`REDEEM_TOKEN_TTL_SECONDS`)
|
||||
@@ -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`
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
description: 杜康好客前端 H5 / Admin 编码规范与 OWNER 边界
|
||||
globs: apps/**/*.{tsx,ts}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 前端规范
|
||||
|
||||
## 目录约定
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # PascalCase(AppToast.tsx)
|
||||
├── pages/ # *Page.tsx
|
||||
├── layouts/ # *Layout.tsx
|
||||
├── contexts/ # *Context.tsx
|
||||
├── lib/ # kebab-case(api.ts, client-location.ts)
|
||||
├── App.tsx
|
||||
└── main.tsx
|
||||
```
|
||||
|
||||
## 四 App
|
||||
|
||||
| App | 端口 | 负责人 | 样式 | X-Client-App |
|
||||
|-----|------|--------|------|--------------|
|
||||
| h5-user | 5173 | jacy-dukang | shared-ui tokens | USER_H5 |
|
||||
| h5-shop | 5174 | jacy-dukang | shared-ui tokens | SHOP_H5 |
|
||||
| h5-partner | 5175 | 刘京尧 | shared-ui tokens | PARTNER_H5 |
|
||||
| admin-web | 5175 | jacy-dukang | Ant Design 5 | HQ_WEB |
|
||||
|
||||
> h5-partner 与 admin-web 勿同时 dev。H5 三端用 `@dukang/shared-ui`;admin-web 独立 Ant Design。
|
||||
|
||||
## 隔离与请求
|
||||
|
||||
- 禁止 import 其他 `apps/*` 或 `server/*`
|
||||
- 类型从 `@dukang/shared-types` import
|
||||
- Base: `/api/v1`;Headers: `Authorization` + `X-Client-App`
|
||||
- Vite dev 代理 `/api` → `localhost:3000`
|
||||
|
||||
## UI 约束
|
||||
|
||||
- C 端订单 **5 Tab**(含 pending_ship)
|
||||
- 门店列表仅 `OPEN` 状态
|
||||
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
||||
|
||||
## App 归属(勿改他端)
|
||||
|
||||
| App | 勿改 |
|
||||
|-----|------|
|
||||
| h5-user | shop 核销、partner 录店 |
|
||||
| h5-shop | user 出码、admin 报表 |
|
||||
| h5-partner | user 下单、admin 开城 |
|
||||
| admin-web | 非 V2 小程序规格 |
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
description: Git 提交与 PR 协作规范
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git 规范
|
||||
|
||||
## 提交格式
|
||||
|
||||
Conventional Commits,scope = 端或模块名:
|
||||
|
||||
```
|
||||
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
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
description: 杜康好客共享包(shared-types / domain / shared-ui)规范
|
||||
globs: packages/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 共享包规范
|
||||
|
||||
## 包职责
|
||||
|
||||
| 包 | 用途 | 约束 |
|
||||
|----|------|------|
|
||||
| `@dukang/shared-types` | DTO、枚举、错误码、JWT Payload | 前后端唯一契约 |
|
||||
| `@dukang/domain` | 纯函数业务规则 | **禁止 IO**,含 Vitest 单测 |
|
||||
| `@dukang/shared-ui` | H5 共享组件 + `tokens.css` | 仅 H5 三端使用 |
|
||||
|
||||
## shared-types
|
||||
|
||||
- 枚举用 `SCREAMING_SNAKE_CASE`(如 `OrderStatus.PENDING_PAY`)
|
||||
- API Tab 查询用 snake_case(`pending_pay`)
|
||||
- **加法优先**:新字段 optional;删除/改名先 deprecated
|
||||
- 改 API 必须同步 V2 手册 §六 与 shared-types
|
||||
|
||||
## domain
|
||||
|
||||
- 纯函数,无 IO(无 Prisma/Redis/HTTP)
|
||||
- 起购 2/6 瓶、权益 `benefitAmount ?? price`、V3 两路径核销规则在此实现
|
||||
- 变更必须有单元测试
|
||||
|
||||
```typescript
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
// 同城起购 2 瓶 / 跨城 6 瓶
|
||||
// 直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
||||
```
|
||||
|
||||
## shared-ui
|
||||
|
||||
- 跨 App 组件;禁止 import 特定 App 代码
|
||||
- admin-web 使用 Ant Design,不引用 shared-ui
|
||||
|
||||
## 破坏性改动
|
||||
|
||||
需通知 **jacy-dukang** 与 **刘京尧**(若影响 partner/shop API)并在 PR 说明影响面。Prisma 迁移需双方 Review(涉及 store/redeem 表时)。
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
description: Prisma schema 变更需对齐 V2 手册 §五 并经表 OWNER Review
|
||||
globs: server/dukang-api/prisma/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Prisma / 数据库规则
|
||||
|
||||
## 事实源
|
||||
|
||||
V2 手册 §五(v3.1,28 表)。schema 与手册保持一致。
|
||||
|
||||
## 变更流程
|
||||
|
||||
1. 确认受影响 Module OWNER(见 `server/dukang-api/AGENTS.md` 表映射)
|
||||
2. 修改 `schema.prisma`
|
||||
3. `pnpm db:validate`
|
||||
4. PR 需对应 OWNER + Lead Review
|
||||
|
||||
## 关键约定
|
||||
|
||||
- 无 `payments` / `user_order_item` / `redeem_tokens` 表
|
||||
- 核销码仅 Redis **3 分钟**(`REDEEM_TOKEN_TTL_SECONDS=180`)
|
||||
- 支付流水 → `log_third_party` + `user_order.pay_*`
|
||||
- 业务事件 → `common_event`;埋点 → `log_user_analytics`
|
||||
|
||||
## preV1
|
||||
|
||||
不删表、不改 V2 字段语义。Seed:`prisma/seed-v31.ts`
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
name: dukang-coding
|
||||
description: >-
|
||||
Implements 杜康好客 features following V2/preV1 coding manuals, module OWNER
|
||||
boundaries, and shared-types contracts. Use when coding APIs, pages, Prisma logic,
|
||||
task cards, or fixing bugs — not for PRD/requirements review (use @dukang-project).
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 杜康好客 · 编码 Skill
|
||||
|
||||
## 何时启用
|
||||
|
||||
- 实现/修复 `apps/*`、`server/dukang-api`、`packages/*` 功能
|
||||
- 实现任务卡(`M2-BE-TRD-002`、`P1-M2-002` 等)
|
||||
- 用户给出任务卡 ID、说「开始 coding」或 @dukang-coding
|
||||
- 修复 V1 范围 Bug
|
||||
|
||||
**不用于**:写 PRD、评审需求、纯文档问答 → 用 @dukang-project 或直接读手册
|
||||
|
||||
## 会话启动(按序)
|
||||
|
||||
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`;preV1 → preV1 手册;V2 蓝图 → V2 手册
|
||||
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 表 → **只改本 OWNER 路径**
|
||||
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
|
||||
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
|
||||
5. 编码前过一遍下方 Checklist
|
||||
|
||||
**需求变更** → V3.0 只改 `杜康好客-v3-PRD.md`,编码时不改业务规则。
|
||||
|
||||
## 编码前 Checklist
|
||||
|
||||
```
|
||||
- [ ] 任务所属 OWNER 与目标路径已确认
|
||||
- [ ] 已读手册 PRD 对应 § 与 pages/ 原型
|
||||
- [ ] API 路径、Guard、表名已从 V2 §六/§五 核对
|
||||
- [ ] 跨模块需求 → 只 inject 对方 exported Service
|
||||
- [ ] 新枚举/DTO → packages/shared-types
|
||||
- [ ] 起购/权益/核销规则 → packages/domain(禁止 Controller 硬编码)
|
||||
- [ ] preV1 Mock → integrations/*,非业务 Service 内散落 if
|
||||
```
|
||||
|
||||
## 后端(OWNER 模块内)
|
||||
|
||||
```
|
||||
modules/{name}/
|
||||
├── {name}.module.ts # exports: [XxxService] 唯一出口
|
||||
├── {name}.controller.ts
|
||||
├── {name}.service.ts # 仅写本模块 Prisma 表
|
||||
└── dto/
|
||||
```
|
||||
|
||||
Workflow:DTO → Service(本模块表)→ Controller + Guard → 同步 shared-types。
|
||||
|
||||
跨模块示例(允许):
|
||||
|
||||
```typescript
|
||||
// trade.service.ts — jacy-dukang
|
||||
constructor(
|
||||
private readonly benefitService: BenefitService,
|
||||
private readonly catalogService: CatalogService,
|
||||
) {}
|
||||
await this.benefitService.grantOnOrderPaid(orderId);
|
||||
```
|
||||
|
||||
跨模块示例(禁止):
|
||||
|
||||
```typescript
|
||||
// redeem.service.ts — ❌ 禁止
|
||||
await this.prisma.order.update({ ... });
|
||||
```
|
||||
|
||||
## 前端(单 App 内)
|
||||
|
||||
1. 路由对照 `pages/ROUTE_MAP.md`
|
||||
2. `src/lib/api.ts` 统一 fetch + `X-Client-App`
|
||||
3. 类型从 `@dukang/shared-types` import
|
||||
4. preV1 App:`h5-user` / `h5-shop` / `h5-partner` / `admin-web`
|
||||
5. C 端订单 Tab(V3.0):`待付款 | 已付款 | 已完成`
|
||||
|
||||
## 数据库变更
|
||||
|
||||
1. 改 `server/dukang-api/prisma/schema.prisma` 对齐手册 §五
|
||||
2. 迁移需 OWNER Review(store/redeem 表 → 刘景尧)
|
||||
3. 初始化 SQL:`server/dukang-api/prisma/init_v3.sql`
|
||||
|
||||
## 核心业务(packages/domain)
|
||||
|
||||
```typescript
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
||||
// 同城 min 2 瓶 / 跨城 min 6 瓶
|
||||
|
||||
// 支付成功 → log_third_party + user_order.pay_status=PAID
|
||||
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
|
||||
// 埋点 → log_user_analytics;业务审计 → common_event
|
||||
```
|
||||
|
||||
## 核销并发(M3+)
|
||||
|
||||
- Redis `redeem:token:{token}` EX 300
|
||||
- 事务 + `user_benefit_coupon.version` 乐观锁
|
||||
- 写 `user_redeem_record` + `store_payout`(PENDING) + `common_event(BENEFIT_LEDGER, REDEEM)`
|
||||
|
||||
## 资源上传
|
||||
|
||||
`POST /common/resources/upload-token` → OSS 直传 → `POST /common/resources`
|
||||
|
||||
## 完成定义
|
||||
|
||||
- 任务卡验收项全部满足
|
||||
- 无跨模块 Prisma 直写
|
||||
- `packages/domain` 相关单测通过
|
||||
- `pnpm lint` 无新增错误
|
||||
- 改了 API/表 → 同步手册与 shared-types
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 模块/表/路由:[reference-backend.md](reference-backend.md)
|
||||
- 页面↔API 速查:[reference-frontend.md](reference-frontend.md)
|
||||
- preV1 Mock:`dukang-prev1` skill
|
||||
- 详细清单:根目录 `skills.md`
|
||||
@@ -1,160 +0,0 @@
|
||||
# 后端速查(杜康好客 v3.1)
|
||||
|
||||
> 完整 DDL/API 以 V2 手册 §五/§六 为准。
|
||||
|
||||
## API Base
|
||||
|
||||
- Prefix: `/api/v1`
|
||||
- Response: `{ code: 0, message: 'ok', data }`
|
||||
- JWT: `actorType` + `actorId` + `clientApp`
|
||||
- 请求头:`Authorization`、`X-Client-App`
|
||||
|
||||
## clientApp → actorType → 表
|
||||
|
||||
| preV1 clientApp | V2 clientApp | actorType | 账号表 |
|
||||
|-----------------|--------------|-----------|--------|
|
||||
| USER_H5 | USER_MINI | USER | user_user |
|
||||
| SHOP_H5 | SHOP_H5 | STORE | store_account |
|
||||
| PARTNER_H5 | PARTNER_MINI | PARTNER | partner_account |
|
||||
| HQ_WEB | HQ_MINI | HQ | hq_account |
|
||||
|
||||
## Guard 对照
|
||||
|
||||
| Guard | 用途 |
|
||||
|-------|------|
|
||||
| JwtAuthGuard | 需登录 |
|
||||
| PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
|
||||
| OptionalJwtAuthGuard | 可选登录(bootstrap) |
|
||||
|
||||
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
||||
|
||||
## Module → API 前缀
|
||||
|
||||
| Module | 前缀示例 | 负责人 |
|
||||
|--------|----------|--------|
|
||||
| iam | `/auth`, `/user` | jacy-dukang |
|
||||
| catalog | `/catalog`, `/admin/cities`, `/admin/products` | jacy-dukang |
|
||||
| trade | `/trade`, `/partner/orders`, `/admin/orders` | jacy-dukang |
|
||||
| benefit | `/benefit` | jacy-dukang |
|
||||
| store | `/stores`, `/partner/stores`, `/admin/store-audits` | 刘景尧 |
|
||||
| redeem | `/redeem`, `/shop/redeem` | 刘景尧 |
|
||||
| settlement | `/settlement`, `/partner/settlement`, `/admin/settlement` | jacy-dukang |
|
||||
| ops | `/admin/dashboard`, `/admin/reports` | jacy-dukang |
|
||||
| analytics | `/analytics`, `/promo/touch` | jacy-dukang |
|
||||
| callbacks | `/callbacks/wechat/pay`, `/callbacks/xfx/delivery` | jacy-dukang |
|
||||
|
||||
## 表前缀 → Module 写 OWNER
|
||||
|
||||
| 前缀 | 示例表 | 负责人 |
|
||||
|------|--------|--------|
|
||||
| user_ | user_user, user_order, user_benefit_coupon | jacy-dukang |
|
||||
| store_ | store_store, store_account, store_payout | 刘景尧 / jacy-dukang(settlement) |
|
||||
| partner_ | partner_partner, partner_bill | 刘景尧 / jacy-dukang(settlement) |
|
||||
| hq_ | hq_account | jacy-dukang |
|
||||
| common_ | common_product_item, common_event, common_city | jacy-dukang |
|
||||
| log_ | log_third_party, log_user_analytics | 写入方 Module |
|
||||
|
||||
**无以下 V1 表**:payments、user_order_item、redeem_tokens(DB)。
|
||||
|
||||
## Controller 路由(当前实现)
|
||||
|
||||
### iam
|
||||
|
||||
| 路径 | Controller |
|
||||
|------|-----------|
|
||||
| `POST /auth/session/bootstrap` | UserAuthController |
|
||||
| `POST /auth/sms/send` | UserAuthController |
|
||||
| `POST /auth/sms/login` | UserAuthController |
|
||||
| `GET /user/me` | UserAuthController |
|
||||
| `GET/POST/PUT/DELETE /user/addresses` | UserAddressController |
|
||||
| `POST /shop/auth/*` | ShopAuthController |
|
||||
| `POST /partner/auth/*` | PartnerAuthController |
|
||||
| `POST /admin/auth/login` | AdminAuthController |
|
||||
|
||||
### catalog / trade / benefit / redeem
|
||||
|
||||
| 路径 | Module |
|
||||
|------|--------|
|
||||
| `GET /catalog/*` | catalog |
|
||||
| `POST/GET /trade/orders` | trade |
|
||||
| `GET/POST /benefit/*` | benefit |
|
||||
| `POST /redeem/*` | redeem(C 端) |
|
||||
| `POST /shop/redeem/*` | redeem(门店) |
|
||||
|
||||
### store / settlement
|
||||
|
||||
| 路径 | Module |
|
||||
|------|--------|
|
||||
| `GET /stores` | store(C 端门店列表) |
|
||||
| `GET/POST /partner/stores` | store |
|
||||
| `GET /partner/dashboard` | store |
|
||||
| `GET /shop/store` | store |
|
||||
| `GET /shop/dashboard` | store |
|
||||
| `GET /partner/settlement/*` | settlement |
|
||||
| `GET /partner/me` | settlement |
|
||||
| `GET /partner/orders` | trade |
|
||||
|
||||
### ops(admin-web)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `/admin/dashboard` | 看板 |
|
||||
| `/admin/users` | C 端用户 |
|
||||
| `/admin/orders` | 订单 |
|
||||
| `/admin/stores` | 门店 |
|
||||
| `/admin/store-accounts` | 门店账号 |
|
||||
| `/admin/store-media` | 门店媒体 |
|
||||
| `/admin/partners` | 合伙人 |
|
||||
| `/admin/partner-accounts` | 合伙人账号 |
|
||||
| `/admin/cities` | 开城 |
|
||||
| `/admin/hq-accounts` | 总部账号 |
|
||||
| `/admin/benefit/coupons` | 权益券 |
|
||||
| `/admin/benefit/ledgers` | 权益流水 |
|
||||
| `/admin/redeem-records` | 核销记录 |
|
||||
| `/admin/deliveries` | 配送 |
|
||||
|
||||
### analytics / health
|
||||
|
||||
| 路径 | Module |
|
||||
|------|--------|
|
||||
| `POST /analytics/*` | analytics |
|
||||
| `GET /health` | health |
|
||||
|
||||
## 关键跨模块调用链
|
||||
|
||||
**Mock/真实支付成功**
|
||||
|
||||
```
|
||||
PayProvider → TradeService.handlePaySuccess()
|
||||
→ BenefitService.grantOnOrderPaid(orderId)
|
||||
→ OrderDelivery 预创建
|
||||
→ common_event(ORDER_STATUS)
|
||||
```
|
||||
|
||||
**核销确认**
|
||||
|
||||
```
|
||||
RedeemService.confirm()
|
||||
→ BenefitService.deduct(couponId)
|
||||
→ SettlementService.createStorePayout(redeemRecordId)
|
||||
```
|
||||
|
||||
## 禁止依赖(PR 拒绝)
|
||||
|
||||
| # | 禁止 |
|
||||
|---|------|
|
||||
| F1 | redeem 直写 order |
|
||||
| F2 | store → trade/benefit |
|
||||
| F3 | benefit → trade/redeem |
|
||||
| F4 | trade → redeem |
|
||||
| F5 | benefit → redeem |
|
||||
| F6 | catalog → trade/settlement |
|
||||
| F9 | apps import server 源码 |
|
||||
|
||||
## integrations(preV1)
|
||||
|
||||
| Flag | Provider | 行为 |
|
||||
|------|----------|------|
|
||||
| MOCK_SMS=true | sms.mock | 固定码 123456 |
|
||||
| MOCK_PAY=true | pay.mock | 同步 PAID + 发券 |
|
||||
| MOCK_DELIVERY_AUTO=true | delivery.mock | BullMQ 自动推进状态 |
|
||||
@@ -1,127 +0,0 @@
|
||||
# 前端速查(preV1 三 H5 + admin-web)
|
||||
|
||||
> 路由事实源:[`pages/ROUTE_MAP.md`](../../../pages/ROUTE_MAP.md)
|
||||
|
||||
## 四 App 概览
|
||||
|
||||
| App | 目录 | 端口 | 负责人 | X-Client-App | 原型 |
|
||||
|-----|------|------|--------|--------------|------|
|
||||
| h5-user | apps/h5-user | 5173 | jacy-dukang | USER_H5 | pages/user/ |
|
||||
| h5-shop | apps/h5-shop | 5174 | 刘景尧 | SHOP_H5 | pages/shop/ |
|
||||
| h5-partner | apps/h5-partner | 5175 | 刘景尧 | PARTNER_H5 | pages/partner/ |
|
||||
| admin-web | apps/admin-web | 5175 | jacy-dukang | HQ_WEB | pages/hq/ |
|
||||
|
||||
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
||||
|
||||
Vite 代理:`/api` → `localhost:3000`。
|
||||
|
||||
## 共享包
|
||||
|
||||
```typescript
|
||||
import { OrderStatus, ClientApp } from '@dukang/shared-types';
|
||||
import { PageHeader } from '@dukang/shared-ui';
|
||||
```
|
||||
|
||||
H5 三端引用 `@dukang/shared-ui`(`tokens.css`)。admin-web 使用 Ant Design 5,不引用 shared-ui。
|
||||
|
||||
## h5-user 路由(jacy-dukang)
|
||||
|
||||
| 路由 | Page | 主要 API |
|
||||
|------|------|----------|
|
||||
| /login | LoginPage | /auth/* |
|
||||
| / | HomePage | /catalog |
|
||||
| /product/:id | ProductDetailPage | /catalog |
|
||||
| /order/confirm | OrderConfirmPage | /trade/orders/preview |
|
||||
| /pay | PayPage | /trade/orders/:id/pay |
|
||||
| /orders | OrderListPage | /trade/orders?tab= |
|
||||
| /orders/:id | OrderDetailPage | /trade/orders/:id |
|
||||
| /stores | StoreListPage | /stores |
|
||||
| /stores/:id | StoreDetailPage | /stores/:id |
|
||||
| /benefit | BenefitPage | /benefit |
|
||||
| /benefit/:id | BenefitDetailPage | /benefit/:id |
|
||||
| /redeem | RedeemPage | /redeem |
|
||||
| /redeem/code | RedeemCodePage | /redeem |
|
||||
| /redeem/success | RedeemSuccessPage | — |
|
||||
| /addresses | AddressListPage | /user/addresses |
|
||||
| /mine | MinePage | /user/me |
|
||||
|
||||
### 订单 5 Tab
|
||||
|
||||
```
|
||||
待付款 | 已付款 (`paid`) | 已完成
|
||||
```
|
||||
|
||||
## h5-shop 路由(刘景尧)
|
||||
|
||||
| 路由 | Page | 主要 API |
|
||||
|------|------|----------|
|
||||
| /login | LoginPage | /shop/auth/* |
|
||||
| / | HomePage | /shop/dashboard |
|
||||
| /redeem | RedeemConfirmPage | /shop/redeem/* |
|
||||
| /redeem/success | RedeemSuccessPage | — |
|
||||
| /records | RecordsPage | /shop/redeem/records |
|
||||
| /status | StatusPage | /shop/store |
|
||||
| /mine | MinePage | /shop/store |
|
||||
|
||||
## h5-partner 路由(刘景尧)
|
||||
|
||||
| 路由 | Page | 主要 API |
|
||||
|------|------|----------|
|
||||
| /login | LoginPage | /partner/auth/* |
|
||||
| / | HomePage | /partner/dashboard |
|
||||
| /stores | StoreListPage | /partner/stores |
|
||||
| /stores/new | StoreCreatePage | /partner/stores |
|
||||
| /stores/:id | StoreDetailPage | /partner/stores/:id |
|
||||
| /orders | OrderListPage | /partner/orders |
|
||||
| /orders/:id | OrderDetailPage | /partner/orders/:id |
|
||||
| /center | CenterPage | /partner/me |
|
||||
| /center/bills | BillsPage | /partner/settlement/* |
|
||||
| /reshipments | ReshipPage | /partner/after-sales/reshipments |
|
||||
| /reports/weekly | WeeklyReportPage | /partner/reports/weekly |
|
||||
| /leaderboard | LeaderboardPage | /partner/dashboard/leaderboard |
|
||||
| — | Mock 推进配送 | POST /partner/orders/:id/mock-advance-delivery |
|
||||
|
||||
## admin-web 路由(jacy-dukang)
|
||||
|
||||
| 路由 | Page | 主要 API |
|
||||
|------|------|----------|
|
||||
| /login | LoginPage | /admin/auth/login |
|
||||
| / | DashboardPage | /admin/dashboard |
|
||||
| /users | UsersPage | /admin/users |
|
||||
| /orders | OrdersPage | /admin/orders |
|
||||
| /stores | StoresPage | /admin/stores |
|
||||
| /store-accounts | StoreAccountsPage | /admin/store-accounts |
|
||||
| /store-media | StoreMediaPage | /admin/store-media |
|
||||
| /partners | PartnersPage | /admin/partners |
|
||||
| /partner-accounts | PartnerAccountsPage | /admin/partner-accounts |
|
||||
| /cities | CitiesPage | /admin/cities |
|
||||
| /hq-accounts | HqAccountsPage | /admin/hq-accounts |
|
||||
| /benefit/coupons | BenefitCouponsPage | /admin/benefit/coupons |
|
||||
| /benefit/ledgers | BenefitLedgersPage | /admin/benefit/ledgers |
|
||||
| /redeem-records | RedeemRecordsPage | /admin/redeem-records |
|
||||
| /deliveries | DeliveriesPage | /admin/deliveries |
|
||||
|
||||
## 端 → API 前缀
|
||||
|
||||
| 端 | API 前缀 | NestJS Module |
|
||||
|----|----------|---------------|
|
||||
| C 端 | /auth, /user, /catalog, /trade, /benefit, /redeem, /stores | iam, catalog, trade, benefit, redeem, store |
|
||||
| 门店 | /shop/auth, /shop/redeem, /shop/store | iam, redeem, store |
|
||||
| 合伙人 | /partner/* | iam, store, trade, settlement |
|
||||
| 总部 | /admin/* | iam, catalog, store, trade, settlement, ops |
|
||||
|
||||
## UI 硬约束
|
||||
|
||||
- C 端订单 **3 Tab**(`pending_pay` / `paid` / `completed`)
|
||||
- 个人中心无会员等级(V1 不做会员体系)
|
||||
- 清香型 4 SKU 可购;酱香/浓香灰态
|
||||
- 门店列表仅 `OPEN` 状态
|
||||
- 原型只读:`pages/{端}/*/code.html`,勿改原型目录
|
||||
|
||||
## preV1 与 V2 UI 差异
|
||||
|
||||
| 项 | preV1 | V2 |
|
||||
|----|-------|-----|
|
||||
| C/合伙人载体 | H5 | 微信小程序 |
|
||||
| 总部 | admin-web 内部 | mini-hq 小程序 |
|
||||
| 支付页 | Mock 同步成功 | 微信 JSAPI |
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: dukang-prev1
|
||||
description: >-
|
||||
Guides preV1 Mock integration phase for Dukang Haoke: H5 three-client setup,
|
||||
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.
|
||||
---
|
||||
|
||||
# 杜康好客 · preV1 Mock Skill
|
||||
|
||||
## 定位
|
||||
|
||||
preV1 = V2 之上的**裁剪实现**:**不删表、不删 API 路径、不改字段语义**。
|
||||
|
||||
权威文档:[`杜康好客-preV1编码手册.md`](../../杜康好客-preV1编码手册.md)
|
||||
|
||||
## 六条裁剪(必记)
|
||||
|
||||
1. **无 HQ 小程序** → `admin-web` 内部替代;`/admin/*` 保留,前端不暴露 AdminAuth 给 C/B/D 端
|
||||
2. **三端均 H5** → `USER_H5` / `SHOP_H5` / `PARTNER_H5`
|
||||
3. **无微信登录** → 仅 SMS;wechat 路由返回 501 或 Flag 关闭
|
||||
4. **验证码 Mock** → `123456`,`MOCK_SMS=true`
|
||||
5. **配送 Mock** → BullMQ 自动推进或合伙人 `mock-advance-delivery`
|
||||
6. **支付 Mock** → `POST /trade/orders/:id/pay` 同步成功 + **真实发券**
|
||||
|
||||
## 环境变量(`.env.example`)
|
||||
|
||||
```bash
|
||||
MOCK_SMS=true
|
||||
MOCK_SMS_CODE=123456
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
```
|
||||
|
||||
配置加载:`packages/shared-types/src/config.ts` → `loadAppConfig()`
|
||||
|
||||
## Mock 代码位置(禁止散落)
|
||||
|
||||
```
|
||||
server/dukang-api/src/integrations/
|
||||
├── sms/sms.interface.ts + sms.mock.provider.ts
|
||||
├── pay/pay.interface.ts + pay.mock.provider.ts
|
||||
└── delivery/delivery.interface.ts + delivery.mock.provider.ts
|
||||
```
|
||||
|
||||
业务 Module 只 inject **interface**,由 Nest DI 切换 Mock/Real。
|
||||
|
||||
## preV1 必做 vs 跳过
|
||||
|
||||
| 必做 | 跳过(V2 补) |
|
||||
|------|---------------|
|
||||
| 三端 SMS 登录 | 微信登录/支付/退款 |
|
||||
| Mock 支付 + 真实发券 | 微信 prepay/回调验签 |
|
||||
| 5 Tab 订单 | HQ 小程序 UI |
|
||||
| Redis 核销码 + 门店核销 | 真实小飞侠/物流 |
|
||||
| 合伙人录店 + AUTO_APPROVE | 总部人工审核 UI |
|
||||
| 可选埋点 | 推广码 HQ UI、T+30 真实打款 |
|
||||
|
||||
## HQ 能力替代(Seed)
|
||||
|
||||
| V2 HQ | preV1 |
|
||||
|-------|-------|
|
||||
| 开城/商品 CRUD | Seed 固定 + admin-web 或改 seed |
|
||||
| 门店审核 | AUTO_APPROVE_STORE=true |
|
||||
| 退款/客服 | 跳过 |
|
||||
| 结算 | store_payout PENDING;可 Seed 演示账单 |
|
||||
|
||||
Seed:`server/dukang-api/prisma/seed-v31.ts`
|
||||
|
||||
## 测试账号
|
||||
|
||||
| 角色 | 手机号 |
|
||||
|------|--------|
|
||||
| C 端 | 13800000001 |
|
||||
| 门店 | 13900000001 |
|
||||
| 合伙人 | 13700000001 |
|
||||
|
||||
## 冒烟
|
||||
|
||||
```bash
|
||||
node scripts/smoke-prev1.mjs
|
||||
```
|
||||
|
||||
## preV1 → V2 切换检查
|
||||
|
||||
按 preV1 手册 §8.2 逐项关闭 Mock Flag,**无需重构表结构**。
|
||||
|
||||
## 边界提醒
|
||||
|
||||
- preV1 skill **不负责**实现 V2 微信 SDK;遇到真实支付/短信需求 → 停止并切换 V2 手册 §九
|
||||
- 改 Mock 行为时 **仍须** 遵守 OWNER 模块边界
|
||||
- admin-web 是 preV1 内部工具,勿与 V2 `pages/hq/` 原型混为一谈
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
name: dukang-project
|
||||
description: >-
|
||||
Manages 杜康好客 PRD, requirements review, prototype mapping, and task card
|
||||
planning. Use when writing PRD, reviewing requirements, mapping prototypes to
|
||||
features, or splitting task cards. Not for daily coding (use @dukang-coding).
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 杜康好客 · 项目/需求 Skill
|
||||
|
||||
## 何时启用
|
||||
|
||||
- 写或改 PRD、业务规则
|
||||
- 评审需求、对照原型
|
||||
- 拆分或规划任务卡
|
||||
- 用户 @dukang-project
|
||||
|
||||
**不用于**:日常编码、修 Bug(用 @dukang-coding)
|
||||
|
||||
## 文档阅读顺序
|
||||
|
||||
| 优先级 | 文档 | 用途 |
|
||||
|--------|------|------|
|
||||
| 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 **不再作为需求依据**。
|
||||
|
||||
## 需求变更规则
|
||||
|
||||
- **只改** `杜康好客-v3-PRD.md`;进度同步 `杜康好客-v3-现状对照.md`
|
||||
- 编码 Agent **不得**边写代码边改业务规则
|
||||
- 改 API/表结构 → 同步 v3-PRD + `packages/shared-types`
|
||||
|
||||
## 原型 → 模块映射
|
||||
|
||||
| 端 | 原型目录 | 主要 API 前缀 | NestJS Module |
|
||||
|----|----------|---------------|---------------|
|
||||
| C 端 | pages/user/ | /auth, /user, /catalog, /trade, /benefit, /redeem, /stores | iam, catalog, trade, benefit, redeem, store |
|
||||
| 门店 | pages/shop/ | /shop/auth, /shop/redeem, /shop/store | iam, redeem, store |
|
||||
| 合伙人 | pages/partner/ | /partner/* | iam, store, trade, settlement |
|
||||
| 总部 | pages/hq/ | /admin/* | iam, catalog, store, trade, settlement, ops |
|
||||
|
||||
## 任务卡格式
|
||||
|
||||
- ID 格式:`Mx-LAYER-NNN`(如 `M2-BE-TRD-002`)
|
||||
- 任务卡全集:V2 手册 §七 附录 A
|
||||
- preV1 任务卡:preV1 手册 §9
|
||||
|
||||
## 团队 OWNER
|
||||
|
||||
| OWNER | 前端 | 后端 Module |
|
||||
|-------|------|-------------|
|
||||
| A | h5-user | iam, trade, benefit, analytics |
|
||||
| B | h5-partner | store |
|
||||
| C | admin-web | catalog, settlement, ops |
|
||||
| D | h5-shop | redeem |
|
||||
| Lead | packages/* | callbacks, jobs, common |
|
||||
|
||||
## 输出要求
|
||||
|
||||
- 需求文档变更需标注影响模块与 Owner
|
||||
- 新任务卡需含:PRD §、API 路径、DB 表、原型路径、验收标准
|
||||
- 跨模块 PR 需相关双 Owner Review
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
name: dukang-release
|
||||
description: >-
|
||||
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
|
||||
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
|
||||
or @杜康发布 / @dukang-release.
|
||||
---
|
||||
|
||||
# 杜康发布
|
||||
|
||||
同机双栈:`/opt/dukang-staging`(测试)与 `/opt/dukang`(生产)。**仅用户明确要求发布时执行。**
|
||||
|
||||
## 分支与环境
|
||||
|
||||
| 分支 | 环境 | 目录 | 端口 | 域名 |
|
||||
|------|------|------|------|------|
|
||||
| `dev_jacy` | 日常开发 | 本机 | — | — |
|
||||
| `dev` | **staging 测试** | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||
| `main` | **production 生产** | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
||||
|
||||
默认:`dev_jacy` → merge → `dev` → merge → `main` → **发生产**。勿 force push `main`/`dev`。
|
||||
|
||||
> **团队约定(2026-08)**:用户说「发布 / 发版 / 直接发版」且**未特别强调**时 → **发生产(production)**。
|
||||
> 仅当用户明确说「发测试 / staging / 测试环境」时 → 发 staging。
|
||||
|
||||
用户说「发生产 / 上线生产 / 发版」→ 用 `deploy-prod.sh`。
|
||||
用户说「发测试」→ 用 `deploy-staging.sh`。
|
||||
|
||||
## 标准流程(Windows)
|
||||
|
||||
PowerShell 下 **分支名必须加引号**。
|
||||
|
||||
### A. 发测试(日常)
|
||||
|
||||
```powershell
|
||||
# 1. 在 dev_jacy 提交(用户要求时)
|
||||
git push -u origin HEAD
|
||||
git checkout "dev"
|
||||
git pull origin "dev"
|
||||
git merge "dev_jacy" -m "merge(dev_jacy): <简述>"
|
||||
git push origin "dev"
|
||||
git checkout "dev_jacy"
|
||||
|
||||
# 2. 发 staging
|
||||
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy-staging.sh" -- --skip-db
|
||||
```
|
||||
|
||||
### B. 发生产(验收后)
|
||||
|
||||
```powershell
|
||||
git checkout "main"
|
||||
git pull origin "main"
|
||||
git merge "dev" -m "merge(dev): <简述>"
|
||||
git push origin "main"
|
||||
git checkout "dev_jacy"
|
||||
|
||||
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy-prod.sh" -- --skip-db
|
||||
```
|
||||
|
||||
- 默认加 `--skip-db`(无迁移时)
|
||||
- 有 Prisma 迁移:先在 **staging** 跑通(可 `--seed` / `--accept-data-loss`),再生产去掉 `--skip-db`
|
||||
- Staging 微信:正式号 + 全 Mock(`.env.staging`)
|
||||
- `deploy/deploy.env` 提供 `DEPLOY_HOST`、`STAGING_APP_ROOT`、`PROD_APP_ROOT`(勿提交密钥)
|
||||
|
||||
### 首次启用 Staging
|
||||
|
||||
服务器一次性:`bash deploy/bootstrap-staging.sh`(见脚本说明)→ 配 `.env.staging` → DNS `*-test` → nginx → `deploy-staging.sh -- --seed`
|
||||
|
||||
### 同步环境变量
|
||||
|
||||
```powershell
|
||||
bash deploy/sync-api-env.sh staging
|
||||
bash deploy/sync-api-env.sh production
|
||||
```
|
||||
|
||||
## 等待完成
|
||||
|
||||
- 出现 `发版完成`
|
||||
- `exit_code: 0`
|
||||
- `mini-user(h5): 200` / `api: 200`
|
||||
|
||||
## 回复用户
|
||||
|
||||
说明:环境(测试/生产)、commit、建议硬刷验证。测试后台有橙色「测试环境」条。
|
||||
|
||||
## 禁止
|
||||
|
||||
- 不 amend 已推送 commit;不 force push `main`/`dev`
|
||||
- 不把 `.env*` 私钥打进仓库
|
||||
- **禁止**再用 `dev` 直发生产
|
||||
- 用户只说「修好」未说发布 → 只改代码,不发版
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: dukang-task-card
|
||||
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.
|
||||
---
|
||||
|
||||
# 杜康好客 · 任务卡 Skill
|
||||
|
||||
## 原则
|
||||
|
||||
- **一次只做一个任务卡**
|
||||
- 任务卡 ID 命名空间:`DLV-W*-*`(V3.0)· `P1-*` / `M*-*`(**历史,不再作验收依据**)
|
||||
- 需求变更只改 `杜康好客-v3-PRD.md`;进度查 `杜康好客-v3-现状对照.md`
|
||||
|
||||
## 领取流程
|
||||
|
||||
1. **解析 ID** → 确定里程碑与 OWNER
|
||||
- `DLV-W1-M2` → `@dukang-v3` + [reference-waves.md](../dukang-v3/reference-waves.md)
|
||||
- `P1-M2-002` → preV1 §9 + OWNER 见卡片内容
|
||||
- `M2-BE-TRD-002` → V2 §七 附录 A
|
||||
2. **读验收标准** → ACC-ID(V3)或手册中该 ID 的「验收」列
|
||||
3. **读依赖** → REQ-ID、API 路径、DB 表、原型路径
|
||||
4. **确认边界** → 只改 [`AGENTS.md`](../../AGENTS.md) OWNER 表内路径
|
||||
5. **实现** → 配合 `dukang-coding` skill
|
||||
6. **自验 DoD** → 下方清单
|
||||
|
||||
## OWNER 快速映射(2 人)
|
||||
|
||||
| 领域 | 负责人 | 典型 ID |
|
||||
|------|--------|---------|
|
||||
| C 端 FE、h5-user、交易后端 | jacy-dukang | P1-M1-002, M2-* |
|
||||
| admin、catalog、settlement | jacy-dukang | M1-BE-CAT-* |
|
||||
| 合伙人、store | 刘景尧 | P1-M4-001 |
|
||||
| 门店核销、h5-shop | 刘景尧 | P1-M3-002 |
|
||||
| Monorepo/integrations | jacy-dukang | P1-M0-* |
|
||||
|
||||
## preV1 任务卡(精简)
|
||||
|
||||
| ID | 验收要点 |
|
||||
|----|----------|
|
||||
| P1-M0-001 | 三端 `pnpm dev` 可编译 |
|
||||
| P1-M0-003 | `pnpm db:validate` 通过 |
|
||||
| P1-M0-004 | Seed 郑州+4SKU+测试账号 |
|
||||
| P1-M1-001 | 123456 三端登录 |
|
||||
| P1-M1-002 | C 端首页 4 款酒 |
|
||||
| P1-M2-001 | 起购校验 2/6 瓶 |
|
||||
| P1-M2-002 | Mock 支付发券 |
|
||||
| P1-M2-003 | 5 Tab 含 pending_ship |
|
||||
| P1-M3-001 | V3 两路径核销金额限制 |
|
||||
| P1-M3-002 | 门店扫码核销 |
|
||||
| P1-M4-001 | 录店 AUTO_APPROVE → C 端可见 |
|
||||
| P1-M5-001 | 配送自动推进到 COMPLETED |
|
||||
|
||||
完整列表:preV1 手册 §9。
|
||||
|
||||
## 完成定义(每个任务卡)
|
||||
|
||||
```
|
||||
- [ ] 验收列每一条可演示/可脚本验证
|
||||
- [ ] 改动在 OWNER 路径内(跨模块需 inject Service + 双 OWNER 知晓)
|
||||
- [ ] 新枚举/DTO → shared-types
|
||||
- [ ] 纯规则 → domain + 单测(若涉及起购/权益/核销)
|
||||
- [ ] API/表变更 → 同步 V2 手册 §五/§六
|
||||
- [ ] pnpm lint 无新增错误
|
||||
```
|
||||
|
||||
## V3.0 DLV 顺序(当前交付)
|
||||
|
||||
**Wave 1**:DLV-W1-M1 → M2 → M3 → M4(门禁 W1-S1~S4)
|
||||
|
||||
**Wave 2**:DLV-W2-M1 → M2 → M3 → M4(门禁 W2-S1~S5)
|
||||
|
||||
**Wave 3**:DLV-W3-M1 → M2 → M3 → M4 → M5(全量 ACC)
|
||||
|
||||
## 历史里程碑
|
||||
|
||||
**preV1**:P1-M0 → … → P1-M6
|
||||
|
||||
**V2**:M0 → M1 → … → M6 → 上线
|
||||
|
||||
除非用户指定,默认从当前里程碑**下一个未完成**任务卡开始;不确定时先问用户或跑 smoke 判断进度。
|
||||
|
||||
## 输出格式(任务完成时)
|
||||
|
||||
```markdown
|
||||
## 任务卡 {ID} 完成
|
||||
|
||||
**改动范围**:(路径列表)
|
||||
**验收**:(逐条 ✓)
|
||||
**未做/阻塞**:(如有,需其他 OWNER 配合的项)
|
||||
**建议下一步**:(下一任务卡 ID)
|
||||
```
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
name: dukang-v3
|
||||
description: >-
|
||||
Manages 杜康好客 V3.0 PRD (v3-PRD), three-wave delivery (W1/W2/W3), REQ/ACC
|
||||
IDs, and requirements review. Use when implementing V3.0 features, reviewing
|
||||
PRD v1.3 scope, planning Wave gates, or user @dukang-v3. Not for preV1 Mock
|
||||
(use @dukang-prev1) or daily coding alone (pair with @dukang-coding).
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 杜康好客 · V3.0 需求 Skill
|
||||
|
||||
## 何时启用
|
||||
|
||||
- 读/改 V3.0 业务规则与交付范围
|
||||
- 评审需求、对照 PRD REQ-ID / ACC-ID
|
||||
- 规划或验收 Wave 1/2/3 门禁
|
||||
- 用户 @dukang-v3 或提到「v3.0 PRD」「三波交付」「W1-S1」
|
||||
|
||||
**不用于**:preV1 Mock 联调(@dukang-prev1)、纯编码实现(@dukang-coding)
|
||||
|
||||
## 文档阅读顺序
|
||||
|
||||
| 优先级 | 文档 | 用途 |
|
||||
|--------|------|------|
|
||||
| 1 | [`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) | **唯一需求事实源** |
|
||||
| 2 | [`杜康好客-v3-现状对照.md`](../../../杜康好客-v3-现状对照.md) | 已完成/冲突/缺口 |
|
||||
| 3 | [`杜康好客-v3编码手册.md`](../../../杜康好客-v3编码手册.md) | 实现验收、分工 |
|
||||
| 4 | [reference-waves.md](reference-waves.md) | Wave 门禁与 DLV 映射 |
|
||||
| 5 | [reference-req-index.md](reference-req-index.md) | REQ-U/S/P/H 全表 |
|
||||
| 6 | [reference-acc.md](reference-acc.md) | ACC 验收条件 |
|
||||
| 7 | `pages/{user,shop,partner,hq}/` | UI 参照 |
|
||||
|
||||
> V2 / preV1 手册**不再作为需求依据**。
|
||||
|
||||
## V3.0 不可偏离常量
|
||||
|
||||
```
|
||||
权益额 = 订单实付 1:1(永久)
|
||||
同城起购 2 瓶 / 跨城 1 箱(6瓶)
|
||||
门店结算 = 核销额 × 60%
|
||||
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
|
||||
核销码 TTL = 3 分钟;试核销固定 ¥100
|
||||
订单 Tab:待付款 | 已付款 | 已完成
|
||||
C 端门店仅 status=OPEN 且审核通过
|
||||
```
|
||||
|
||||
## 三波交付(严格顺序)
|
||||
|
||||
```
|
||||
Wave 1 (7.10) → Wave 2 (7.15) → Wave 3 (7.22)
|
||||
```
|
||||
|
||||
实现前**必须确认当前 Wave**;不得提前做后续 Wave 功能(除非用户明确要求)。
|
||||
|
||||
| Wave | 门禁 | 一句话 |
|
||||
|------|------|--------|
|
||||
| W1 | W1-S1~S4 | 下单+核销+拓店+合伙人子账号 |
|
||||
| W2 | W2-S1~S5 | 提现+推广码+现场提货+门店子账号+多合伙人 |
|
||||
| W3 | 全量 G1~G5 | 代下单+弱网+多仓+跨城+工单+发票+增长 |
|
||||
|
||||
详情:[reference-waves.md](reference-waves.md)
|
||||
|
||||
## 需求变更规则
|
||||
|
||||
- **只改** `杜康好客-v3-PRD.md` 作为 V3.0 业务事实源
|
||||
- 同步 `杜康好客-v3编码手册.md` + `packages/shared-types` + `packages/domain`
|
||||
- V2/preV1 与 V3.0 冲突时 **V3.0 PRD 优先**
|
||||
- 编码 Agent 不得边写代码边改业务规则
|
||||
|
||||
## 原型 → 模块映射
|
||||
|
||||
| 端 | PRD 形态 | 当前代码 | 后端 Module |
|
||||
|----|----------|----------|-------------|
|
||||
| C 端 | 微信小程序 | `apps/h5-user`(过渡) | iam, catalog, trade, benefit |
|
||||
| 门店 | H5 | `apps/h5-shop` | iam, redeem, store |
|
||||
| 合伙人 | H5 | `apps/h5-partner` | iam, store, trade, settlement |
|
||||
| 总部 | H5 | `apps/admin-web`(过渡) | catalog, store, trade, settlement, ops |
|
||||
|
||||
## 任务卡命名(V3.0)
|
||||
|
||||
- **DLV-W{n}-M{m}**:阶段交付(如 `DLV-W1-M2`)
|
||||
- **REQ-{U|S|P|H}-{nnn}**:功能需求 ID
|
||||
- **ACC-{nnn}** / **ACC-P{nn}** / **ACC-W{nn}**:验收 ID
|
||||
- preV1 `P1-*` 与 V2 `M*-*` 为历史命名,V3.0 新任务以 DLV/REQ/ACC 为准
|
||||
|
||||
领取 DLV 任务时配合 `@dukang-coding` + 对应 Owner agent。
|
||||
|
||||
## 团队 OWNER(2 人)
|
||||
|
||||
| 负责人 | 前端 | 后端 Module |
|
||||
|--------|------|-------------|
|
||||
| jacy-dukang | h5-user/mini-user, admin-web | iam, trade, benefit, catalog, settlement, ops, analytics |
|
||||
| 刘景尧 | h5-shop, h5-partner | store, redeem |
|
||||
|
||||
## 需求评审输出格式
|
||||
|
||||
```markdown
|
||||
## V3.0 需求评审 · {主题}
|
||||
|
||||
**关联**:REQ-xxx / ACC-xxx / Wave N
|
||||
**影响模块**:(NestJS module + app)
|
||||
**Owner**:jacy-dukang | 刘景尧 | 双方
|
||||
**与现状差异**:(代码/手册冲突点)
|
||||
**建议任务**:DLV-Wx-Mx
|
||||
**阻塞/依赖**:(外部对接、前置 Wave)
|
||||
```
|
||||
|
||||
## 子 Agent 选用
|
||||
|
||||
| 场景 | Agent |
|
||||
|------|-------|
|
||||
| V3 波次统筹、跨 Wave 排期 | `v3-delivery-lead` |
|
||||
| C 端下单/权益/支付 | `owner-a-user-trade` |
|
||||
| 总部开城/结算/运营 | `owner-c-catalog-ops` |
|
||||
| 合伙人拓店 | `owner-b-partner-store` |
|
||||
| 门店核销 | `owner-d-shop-redeem` |
|
||||
| packages/integrations/Prisma | `backend-lead` |
|
||||
| PR 边界审查 | `boundary-reviewer` |
|
||||
@@ -1,72 +0,0 @@
|
||||
# V3.0 ACC 验收标准索引
|
||||
|
||||
> 事实源:[`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) §8
|
||||
|
||||
## 交易与履约
|
||||
|
||||
| ACC | 关联 | 验收条件 |
|
||||
|-----|------|----------|
|
||||
| ACC-001 | S1 | 访客可浏览;下单须登录;支付成功→权益 1:1 |
|
||||
| ACC-002 | 同城 | ≥2 瓶免运费;小飞侠;取送拍照;24h 自动完成 |
|
||||
| ACC-003 | 现场提货 | 隐藏入口;支付即完成;有码归码/无码归总部 |
|
||||
| ACC-004 | 跨城 | 未开通城市≥1箱;总部物流到付;佣金归总部 |
|
||||
| ACC-015 | 锁单 | 30 分钟取消;7 天免登 |
|
||||
|
||||
## 核销与门店账号
|
||||
|
||||
| ACC | 关联 | 验收条件 |
|
||||
|-----|------|----------|
|
||||
| ACC-005 | S2 | 扫码+手机号两通道;码/验证码 3 分钟;成功短信 |
|
||||
| ACC-006 | 多店子账号 | W2:一号多店;主账号唯一;店员权限隔离 |
|
||||
| ACC-006a | 门店登录 | 单店直达;多店含角色;记住所选 |
|
||||
| ACC-017 | 弱网 | W1~2 重试+人工;W3 5次拍照兜底;不重复扣款 |
|
||||
| ACC-017a | 弱网 SLA | W3:总部 T+0 处理;超时预警 |
|
||||
|
||||
## 拓店与 SOP
|
||||
|
||||
| ACC | 关联 | 验收条件 |
|
||||
|-----|------|----------|
|
||||
| ACC-007 | S3 | 合伙人三步提交;总部审;C 端营业中可见 |
|
||||
| ACC-019 | SOP | 筛选字段;附件一一致;试核销100;负责人复核;合同同日 |
|
||||
| ACC-020 | 结算细则 | T+1 顺延;异议3日;店内公示;签约主体 |
|
||||
|
||||
## 资金与佣金
|
||||
|
||||
| ACC | 关联 | 验收条件 |
|
||||
|-----|------|----------|
|
||||
| ACC-008 | S4 | 未出账可提;总部打款;FIN 护栏;T+0 审 |
|
||||
| ACC-009 | 佣金 | 默认 0%+3%;门店结算×60%;§4.3 归属 |
|
||||
| ACC-P21 | 多合伙人 | 区域区县互斥;全城唯一 |
|
||||
| ACC-P22 | 佣金配置 | 独立配置;≤5%;快照 |
|
||||
| ACC-P23 | 订单佣金 | 同城区县解析;余量归总部/全城 |
|
||||
| ACC-P24 | 核销佣金 | 归门店归属合伙人;变更不溯及 |
|
||||
| ACC-P25 | 独立账单 | T+30 独立确认打款 |
|
||||
| ACC-P26 | 数据隔离 | 仅本管辖;推广员仅自己的店 |
|
||||
| ACC-P27 | 推广码 | 绑定合伙人;现场有码归码 |
|
||||
| ACC-P28 | 审计 | 管辖/比例/归属变更留痕 |
|
||||
|
||||
## 售后与增长
|
||||
|
||||
| ACC | 关联 | 验收条件 |
|
||||
|-----|------|----------|
|
||||
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
|
||||
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
|
||||
| ACC-012 | 代下单 | W3:客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
|
||||
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
|
||||
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
|
||||
| ACC-018 | 话术 | OPT-012 3 日内对齐 |
|
||||
| ACC-021 | 热力图 | 腾讯位置服务按城市 |
|
||||
|
||||
## 多仓(Wave 3)
|
||||
|
||||
| ACC | 关联 | 验收条件 |
|
||||
|-----|------|----------|
|
||||
| ACC-W01 | 多仓 | 同城多仓;启停 |
|
||||
| ACC-W02 | 仓-合伙人 | 一仓一合伙人;未关联总部直管 |
|
||||
| ACC-W03 | 管仓协同 | 管仓合伙人只读仓+工单协同 |
|
||||
|
||||
## UI 与隐私
|
||||
|
||||
| ACC | 关联 | 验收条件 |
|
||||
|-----|------|----------|
|
||||
| ACC-016 | UI/隐私 | §8 规范;按角色脱敏 |
|
||||
@@ -1,128 +0,0 @@
|
||||
# V3.0 REQ 需求索引
|
||||
|
||||
> 事实源:产品 PRD v1.3 → [`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md)
|
||||
|
||||
## 用户端 REQ-U
|
||||
|
||||
| ID | 模块 | 结论摘要 |
|
||||
|----|------|----------|
|
||||
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
|
||||
| REQ-U-002 | 登录 | 无感登录;下单提示绑定手机号(可选);微信/短信;7 天免登 |
|
||||
| REQ-U-003 | 支付 | 仅微信支付 |
|
||||
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
|
||||
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
|
||||
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
|
||||
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
||||
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 |
|
||||
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 |
|
||||
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
|
||||
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
|
||||
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
|
||||
| REQ-U-013 | 门店列表 | 营业中;搜索;省市区筛选;立即核销 |
|
||||
| REQ-U-014 | 门店详情 | 门头/电话/星级/地址/营业时间/规则/相册 |
|
||||
| REQ-U-015 | 核销页 | 剩余总额;输入金额;码 3 分钟有效 |
|
||||
| REQ-U-016 | 规则弹窗 | 核销前展示(OPT-011) |
|
||||
| REQ-U-017 | 评价 | 核销后引导门店评价 |
|
||||
| REQ-U-018 | 权益页 | 待使用总额;Tab 待使用/已使用;明细 |
|
||||
| REQ-U-019 | 个人中心 | 手机号、权益总额、订单入口 |
|
||||
| REQ-U-020 | 订单 | 待付款/已付款/已完成;物流时间线 |
|
||||
| REQ-U-021 | 发票 | 四组合;总部 2 工作日回传(OPT-001) |
|
||||
| REQ-U-022 | 工单 | 四类型;客服引导建单 |
|
||||
| REQ-U-023 | 问卷 | 支付成功引导;无激励(OPT-004) |
|
||||
| REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 |
|
||||
| REQ-U-025 | 分享 | 微信商品分享+渠道参数 |
|
||||
| REQ-U-026 | 门店规则 | 附件一完整展示 |
|
||||
| REQ-U-027 | 门店套餐 **3.4.10** | 门店详情展示生效套餐;套餐异议申诉入口 |
|
||||
|
||||
## 门店端 REQ-S
|
||||
|
||||
| ID | 模块 | 结论摘要 |
|
||||
|----|------|----------|
|
||||
| REQ-S-001 | 导航 | 首页/核销记录/门店信息 |
|
||||
| REQ-S-002 | 登录 | 主账号/店员;§4.6 流程;7 天免登 |
|
||||
| REQ-S-003 | 多店 | 一号多店选店(Wave 2) |
|
||||
| REQ-S-004 | 扫码核销 | 大按钮;确认即成功 |
|
||||
| REQ-S-005 | 手机号核销 | 查余额→用户验证码→确认 |
|
||||
| REQ-S-006 | 短信 | 核销中/成功文案 |
|
||||
| REQ-S-007 | 今日汇总 | 首页今日核销总金额 |
|
||||
| REQ-S-008 | 弱网 | W1~2 重试;W3 §4.8 |
|
||||
| REQ-S-008a | 弱网弹窗 | W3 拍照兜底文案 |
|
||||
| REQ-S-009 | 核销记录 | 筛今日/7日/1月/全部;到账×60% |
|
||||
| REQ-S-010 | 门店信息 | 营业状态;结算入口;切换门店 |
|
||||
| REQ-S-011 | 结算信息 | T+1;核销额/结算额/状态 |
|
||||
| REQ-S-012 | 提现 | 未出账可提(Wave 2);FIN 护栏 |
|
||||
| REQ-S-013 | 提现记录 | 待审/已打款/驳回 |
|
||||
| REQ-S-014 | 结算异议 | 3 工作日 |
|
||||
| REQ-S-015 | 店内公示 | 模板下载 |
|
||||
| REQ-S-016 | 提现护栏 | 白名单/单日上限提示 |
|
||||
| REQ-S-017 | 子账号 | 创建/停用店员(Wave 2) |
|
||||
| REQ-S-018 | 权限 | §4.6 矩阵 |
|
||||
| REQ-S-019 | 多店选店 | 记住上次所选 |
|
||||
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
|
||||
| REQ-S-021 | 门店套餐 **3.4.10** | 套餐列表/编辑/提审 |
|
||||
|
||||
## 合伙人端 REQ-P
|
||||
|
||||
| ID | 模块 | 结论摘要 |
|
||||
|----|------|----------|
|
||||
| REQ-P-001 | 导航 | 首页/门店管理/合伙人中心 |
|
||||
| REQ-P-002 | 登录 | 7 天免登 |
|
||||
| REQ-P-003 | 推广员 | 菜单裁剪;仅本管辖范围 |
|
||||
| REQ-P-004 | 管理员 | 完整能力 |
|
||||
| REQ-P-005 | 子账号 | 管理员·推广员 |
|
||||
| REQ-P-006 | 门店管理 | 筛审核/营业/区县/创建人 |
|
||||
| REQ-P-007 | 列表字段 | 名称/地址/状态/录入 |
|
||||
| REQ-P-008 | 一号多店 | 创建时二次确认 |
|
||||
| REQ-P-009 | 创建三步 | 基础/证照+附件一/结算 |
|
||||
| REQ-P-010 | 编辑规则 | 待审可改;通过后改资料再审 |
|
||||
| REQ-P-011 | 试核销 | 100 元激活(OPT-009) |
|
||||
| REQ-P-012 | 入驻短信 | OPT-008 |
|
||||
| REQ-P-013 | 首页数据 | GMV/核销/订单三卡 |
|
||||
| REQ-P-014 | 功能区 | 子账号/订单/权益/工单/代下单 |
|
||||
| REQ-P-015 | 排行 | 核销/订单 Top10 |
|
||||
| REQ-P-016 | 订单管理 | 筛时间状态 |
|
||||
| REQ-P-017 | 订单详情 | 佣金快照下钻 |
|
||||
| REQ-P-018 | 好客权益 | 送出/已核销/待核销 |
|
||||
| REQ-P-019 | 工单 | 只读 |
|
||||
| REQ-P-020 | 财务对账 | 本合伙人月账 |
|
||||
| REQ-P-021 | 代下单 | Wave 3 |
|
||||
| REQ-P-022 | 筛选校验 | 不达标标黄 |
|
||||
| REQ-P-023 | 附件一 | 结构化录入 |
|
||||
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
|
||||
| REQ-P-025 | 合同同日 | 同日提交校验 |
|
||||
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
|
||||
| REQ-P-027 | 门店套餐 **3.4.10** | 拓店套餐页;门店套餐列表/编辑/提审 |
|
||||
|
||||
## 总部端 REQ-H
|
||||
|
||||
| ID | 模块 | 结论摘要 |
|
||||
|----|------|----------|
|
||||
| REQ-H-001 | 登录 | 权限账户;7 天免登 |
|
||||
| REQ-H-002 | 商品 | 增删改;上下架排序 |
|
||||
| REQ-H-003 | 营销 | 1:1 权益永久 |
|
||||
| REQ-H-004 | 开通城市 | 省/市/配送/是否开通 |
|
||||
| REQ-H-004a | 创建合伙人 | 全城/区域+佣金;W3 管仓 |
|
||||
| REQ-H-004b | 仓库 | Wave 3 多仓 |
|
||||
| REQ-H-005 | 门店管理 | +归属合伙人筛选 |
|
||||
| REQ-H-006 | 门店审核 | 通过/失败;改归属 |
|
||||
| REQ-H-007 | 管辖佣金 | 区县全覆盖校验 |
|
||||
| REQ-H-008 | 订单 | 全量+佣金快照 |
|
||||
| REQ-H-009 | 权益 | 全量+合伙人筛 |
|
||||
| REQ-H-010 | 工单 | 同意/拒绝+备注+日志 |
|
||||
| REQ-H-011 | 发票 | 2 工作日;上传回传 |
|
||||
| REQ-H-012 | 代下单 | 同合伙人 |
|
||||
| REQ-H-013 | 结算 | 门店+合伙人双 Tab |
|
||||
| REQ-H-014 | 提现审 | T+0;FIN-003 |
|
||||
| REQ-H-014a | 白名单 | FIN-001/002 |
|
||||
| REQ-H-015 | 合伙人账单 | 待确认/待打款 |
|
||||
| REQ-H-016 | 报表 | 城市/合伙人/区县+热力图 |
|
||||
| REQ-H-017 | 推广码 | 绑定合伙人+统计 |
|
||||
| REQ-H-018 | 问卷 | 回收看板 |
|
||||
| REQ-H-019 | 门店评价 | 查看数据 |
|
||||
| REQ-H-020 | 核销异常 | W1~2 人工;W3 列表 |
|
||||
| REQ-H-020a | 待处理核销审 | W3 补核销/驳回 |
|
||||
| REQ-H-021 | 话术 | OPT-012 |
|
||||
| REQ-H-022 | 签约主体 | 山西领势酒业 |
|
||||
| REQ-H-023 | 热力图 | 腾讯位置服务 |
|
||||
| REQ-H-024 | 结算异议 | 门店 3 日异议处理 |
|
||||
| REQ-H-025 | 门店套餐 **3.4.10** | 门店详情套餐 Tab 直存;套餐变更审核通过/驳回 |
|
||||
@@ -1,109 +0,0 @@
|
||||
# V3.0 三波交付参考
|
||||
|
||||
> 事实源:[`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) §9
|
||||
|
||||
## Wave 1 — 核心交易 + 拓店(2026-07-10)
|
||||
|
||||
### 门禁
|
||||
|
||||
| 门禁 | 能力 | ACC |
|
||||
|------|------|-----|
|
||||
| W1-S1 | 用户下单支付,权益 1:1 到账 | ACC-001 |
|
||||
| W1-S2 | 门店核销(扫码 + 手机号) | ACC-005、OPT-007、OPT-011 |
|
||||
| W1-S3 | 合伙人子账号 | REQ-P-005 |
|
||||
| W1-S4 | 合伙人创建门店 → 总部审核 → C 端可见 | ACC-007、OPT-009、OPT-008 |
|
||||
|
||||
### 纳入
|
||||
|
||||
四端登录 7 天免登、商品、**同城**下单支付、小飞侠履约、出码/扫码/手机号核销、规则弹窗、合伙人子账号、门店三步录入、负责人复核、总部审核、试核销 100 元、登录短信。
|
||||
|
||||
### 不含
|
||||
|
||||
提现/打款、推广码、现场提货、代下单、弱网拍照、多合伙人、门店子账号、**跨城**、工单、发票。
|
||||
|
||||
### DLV 拆分
|
||||
|
||||
| ID | 内容 |
|
||||
|----|------|
|
||||
| DLV-W1-M1 | 四端登录、权限、UI Token、商品管理/展示 |
|
||||
| DLV-W1-M2 | 支付、锁单 30 分、同城小飞侠、权益 1:1 |
|
||||
| DLV-W1-M3 | 出码/扫码/手机号核销、规则弹窗 OPT-011 |
|
||||
| DLV-W1-M4 | 合伙人子账号、门店三步、负责人复核、审核、试核销 OPT-009、短信 OPT-008 |
|
||||
|
||||
### Wave 1 弱网/代下单 SOP
|
||||
|
||||
- 弱网:重试 + 总部人工补核销(OPT-006 完整版在 W3)
|
||||
- 代下单:运营人工 SOP(系统能力在 W3 OPT-002)
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — 资金 + 品鉴会 + 组织扩展(2026-07-15)
|
||||
|
||||
### 门禁
|
||||
|
||||
| 门禁 | 能力 | ACC |
|
||||
|------|------|-----|
|
||||
| W2-S1 | 未出账可手动提现 + FIN 护栏 | ACC-008、OPT-010、FIN-001~003 |
|
||||
| W2-S2 | 总部审核打款 | REQ-H-014 |
|
||||
| W2-S3 | 推广码 + 现场提货 | ACC-003/014、OPT-005 |
|
||||
| W2-S4 | 门店主子账号 + 一号多店选店 | REQ-S-017~019、ACC-006 |
|
||||
| W2-S5 | 多合伙人管辖/佣金/数据隔离 | ACC-P21~P28 |
|
||||
|
||||
### 不含
|
||||
|
||||
代下单、弱网 5 次拍照、一城多仓、跨城、工单、发票、问卷、热力图。
|
||||
|
||||
### DLV 拆分
|
||||
|
||||
| ID | 内容 |
|
||||
|----|------|
|
||||
| DLV-W2-M1 | T+1 出账、未出账提现 OPT-010 + FIN、总部审打款 |
|
||||
| DLV-W2-M2 | 现场提货 OPT-005、推广码 REQ-H-017 |
|
||||
| DLV-W2-M3 | 多合伙人 REQ-H-004a/007、区县互斥、佣金快照 |
|
||||
| DLV-W2-M4 | 门店子账号 REQ-S-017~019、多店选店 |
|
||||
|
||||
---
|
||||
|
||||
## Wave 3 — 旁路 + 弱网 + 本期全量(2026-07-22)
|
||||
|
||||
### 门禁
|
||||
|
||||
| 门禁 | 能力 | ACC |
|
||||
|------|------|-----|
|
||||
| W3-S1 | 代下单(总部/合伙人) | ACC-012、OPT-002 |
|
||||
| W3-S2 | 弱网 5 次拍照兜底 + 总部补核销 | ACC-017、OPT-006 |
|
||||
| W3-S3 | 一城多仓 + 合伙人管仓 | ACC-W01~W03 |
|
||||
| W3-S4 | 跨城、工单、发票、合伙人 T+30 对账、问卷/评价/热力图 | G5、OPT-001/003/004/012 |
|
||||
|
||||
### 完成定义
|
||||
|
||||
G1–G5 + OPT-001~012 + 门店签约 SOP S1–S12 全部勾选。
|
||||
|
||||
### DLV 拆分
|
||||
|
||||
| ID | 内容 |
|
||||
|----|------|
|
||||
| DLV-W3-M1 | 代下单 OPT-002 |
|
||||
| DLV-W3-M2 | 弱网 OPT-006 完整版 |
|
||||
| DLV-W3-M3 | 多仓、跨城、工单 OPT-003、发票 OPT-001 |
|
||||
| DLV-W3-M4 | 合伙人月账、问卷/评价/热力图、OPT-012 |
|
||||
| DLV-W3-M5 | ACC 全量回归 |
|
||||
|
||||
---
|
||||
|
||||
## OPT 波次对照
|
||||
|
||||
| OPT | 名称 | 波次 |
|
||||
|-----|------|------|
|
||||
| OPT-001 | 发票 | W3 |
|
||||
| OPT-002 | 代下单 | W3 |
|
||||
| OPT-003 | 工单 | W3 |
|
||||
| OPT-004 | 问卷 | W3 |
|
||||
| OPT-005 | 现场提货 | W2 |
|
||||
| OPT-006 | 弱网兜底 | W3(W1~2 简化版) |
|
||||
| OPT-007 | 手机号核销 | W1 |
|
||||
| OPT-008 | 入驻成功短信 | W1 |
|
||||
| OPT-009 | 试核销 100 元 | W1 |
|
||||
| OPT-010 | 未出账提现 | W2 |
|
||||
| OPT-011 | 核销规则弹窗 | W1 |
|
||||
| OPT-012 | 话术对齐 | W3 |
|
||||
@@ -1,43 +0,0 @@
|
||||
# 杜康好客 CODEOWNERS(2 人团队)
|
||||
# 仓库:阿里云 Codeup · 用户名与平台成员账号一致
|
||||
#
|
||||
# jacy-dukang — 管理员 + 主责(C 端、admin、后端主模块、横切)
|
||||
# 刘京尧 — 合伙人端 + 门店端(h5-partner、h5-shop、store、redeem)
|
||||
|
||||
# 文档 / 横切 / 基础设施
|
||||
/AGENTS.md @jacy-dukang
|
||||
/agent.md @jacy-dukang
|
||||
/conventions.md @jacy-dukang
|
||||
/skills.md @jacy-dukang
|
||||
/杜康好客-V2编码手册.md @jacy-dukang
|
||||
/杜康好客-preV1编码手册.md @jacy-dukang
|
||||
/.cursor/ @jacy-dukang
|
||||
/deploy/ @jacy-dukang
|
||||
/packages/ @jacy-dukang @刘京尧
|
||||
|
||||
# jacy-dukang — C 端 + 交易/权益域 + 总部 admin
|
||||
/apps/h5-user/ @jacy-dukang
|
||||
/apps/admin-web/ @jacy-dukang
|
||||
/server/dukang-api/src/modules/iam/ @jacy-dukang
|
||||
/server/dukang-api/src/modules/trade/ @jacy-dukang
|
||||
/server/dukang-api/src/modules/benefit/ @jacy-dukang
|
||||
/server/dukang-api/src/modules/analytics/ @jacy-dukang
|
||||
/server/dukang-api/src/modules/catalog/ @jacy-dukang
|
||||
/server/dukang-api/src/modules/settlement/ @jacy-dukang
|
||||
/server/dukang-api/src/modules/ops/ @jacy-dukang
|
||||
|
||||
# 刘京尧 — 合伙人端 + 门店端
|
||||
/apps/h5-partner/ @刘京尧
|
||||
/apps/h5-shop/ @刘京尧
|
||||
/server/dukang-api/src/modules/store/ @刘京尧
|
||||
/server/dukang-api/src/modules/redeem/ @刘京尧
|
||||
|
||||
# 横切(主责 jacy-dukang;改接口时 @刘京尧 若影响 partner/shop)
|
||||
/server/dukang-api/src/callbacks/ @jacy-dukang
|
||||
/server/dukang-api/src/jobs/ @jacy-dukang @刘京尧
|
||||
/server/dukang-api/src/common/ @jacy-dukang
|
||||
/server/dukang-api/src/integrations/ @jacy-dukang
|
||||
/server/dukang-api/prisma/ @jacy-dukang @刘京尧
|
||||
|
||||
# 原型参照
|
||||
/pages/ @jacy-dukang
|
||||
@@ -1,21 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm db:generate
|
||||
- run: pnpm test
|
||||
- run: pnpm lint
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
server/dukang-api/.env
|
||||
server/dukang-api/.env.development
|
||||
server/dukang-api/.env.production
|
||||
server/dukang-api/.env.production.local
|
||||
server/dukang-api/.env.staging
|
||||
server/dukang-api/.env.staging.local
|
||||
deploy/deploy.env
|
||||
deploy/.staging-mysql.pass
|
||||
deploy/_ops-*.log
|
||||
deploy/_ops-*.sh
|
||||
|
||||
.DS_Store
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
server/dukang-api/prisma/migrations/
|
||||
debug_v3.xlsx
|
||||
~$debug_v3.xlsx
|
||||
deploy/auto-release.env
|
||||
@@ -1,170 +0,0 @@
|
||||
# 杜康好客 · AGENTS.md
|
||||
|
||||
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。
|
||||
|
||||
## 项目概览
|
||||
|
||||
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
|
||||
|
||||
| 阶段 | 手册 | 说明 |
|
||||
|------|------|------|
|
||||
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
|
||||
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
|
||||
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
|
||||
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
|
||||
|
||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
||||
|
||||
## 启动命令
|
||||
|
||||
```bash
|
||||
# 基础设施
|
||||
cd deploy && docker compose up -d # dukang-v1:MySQL :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:3000/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 冲突时忽略)
|
||||
6. `pages/{user,shop,partner,hq}/` — UI 参照
|
||||
|
||||
> **V2 / preV1 手册不再作为需求依据。**
|
||||
|
||||
## 团队与 OWNER 边界(2 人)
|
||||
|
||||
| 负责人 | Git 账号 | 职责 |
|
||||
|--------|----------|------|
|
||||
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
|
||||
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管**) |
|
||||
|
||||
> **临时分工(2026-07)**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
|
||||
|
||||
逻辑模块边界仍按 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-v3(PRD、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
|
||||
同城起购 2 瓶 / 跨城 6 瓶
|
||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
|
||||
C 端门店仅 status=OPEN
|
||||
订单 Tab:待付款 | 已付款 | 已完成
|
||||
```
|
||||
|
||||
## 环境与发版
|
||||
|
||||
| 环境 | 分支 | 目录 | 端口 | 域名 |
|
||||
|------|------|------|------|------|
|
||||
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
|
||||
| **staging 测试** | `dev` | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.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)
|
||||
@@ -1 +0,0 @@
|
||||
ayPJ4CQqbUcec3jX
|
||||
@@ -1,92 +0,0 @@
|
||||
# 杜康好客 V3 交付版
|
||||
|
||||
四端 H5/WebAdmin + NestJS 单体 API。**V3 交付验收**以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准;V2 为完整蓝图参考;preV1 为 Mock 联调历史参考。
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 1. 基础设施
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose up -d # MySQL :6016 Redis :6017(与 dukang-v3 的 6014/6015 隔离)
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 3. 数据库
|
||||
|
||||
```bash
|
||||
cp server/dukang-api/.env.example server/dukang-api/.env
|
||||
pnpm db:generate
|
||||
cd server/dukang-api
|
||||
npx prisma db push # v3.1 首次切换加 --accept-data-loss
|
||||
pnpm prisma:seed # seed-v31.ts
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
# 终端 1 - API
|
||||
pnpm dev:api
|
||||
|
||||
# 终端 2~4 - 三端 H5
|
||||
pnpm dev:user # http://localhost:5173
|
||||
pnpm dev:shop # http://localhost:5174
|
||||
pnpm dev:partner # http://localhost:5175
|
||||
|
||||
|
||||
改动测试
|
||||
```
|
||||
|
||||
## 测试账号(验证码均为 123456)
|
||||
|
||||
| 端 | 手机号 |
|
||||
|----|--------|
|
||||
| C端用户 | 13800000001 |
|
||||
| 合伙人 | 13700000001 |
|
||||
| HQ | 13600000001(密码登录:`admin` / `dukang@123!`;短信验证码 123456) |
|
||||
|
||||
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`。
|
||||
|
||||
## 主链路冒烟
|
||||
|
||||
```bash
|
||||
node scripts/smoke-v3.mjs # V3 交付验收
|
||||
node scripts/smoke-prev1.mjs # preV1 历史回归
|
||||
```
|
||||
|
||||
验收清单见 [`V3_ACCEPTANCE_CHECKLIST.md`](./V3_ACCEPTANCE_CHECKLIST.md)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `apps/h5-user` · `apps/h5-shop` · `apps/h5-partner` — 三端 H5(Vite + React)
|
||||
- `packages/shared-types` · `packages/domain` — 共享契约与纯函数
|
||||
- `server/dukang-api` — NestJS 单体 API
|
||||
- `pages/` — UI 原型(从 Stitch「4端1.0版本」同步)
|
||||
- `pages/user/` · `pages/shop/` · `pages/partner/` — 各端 screen 的 `code.html` + `screen.png`
|
||||
- `pages/ROUTE_MAP.md` — screen ↔ 路由对照
|
||||
- `pages/stitch_4_1.0/DESIGN.md` — 设计系统
|
||||
- `packages/shared-ui` — 三端共享设计令牌与组件
|
||||
|
||||
## 同步 Stitch 原型
|
||||
|
||||
从 Google Stitch 共享项目「4端1.0版本」拉取最新 HTML/截图:
|
||||
|
||||
```bash
|
||||
# 设置 API Key(或使用 ~/.cursor/mcp.json 中的 stitch 配置)
|
||||
set STITCH_API_KEY=your-key
|
||||
pnpm sync:stitch
|
||||
# 强制重新下载
|
||||
pnpm sync:stitch -- --force
|
||||
```
|
||||
|
||||
## Mock 开关(`.env`)
|
||||
|
||||
- `MOCK_SMS=true` — 固定验证码 123456
|
||||
- `MOCK_PAY=true` — 同步支付成功并发券
|
||||
- `MOCK_DELIVERY_AUTO=true` — BullMQ 自动推进配送状态
|
||||
- `AUTO_APPROVE_STORE=true` — 合伙人录店自动审核通过
|
||||
@@ -1,52 +0,0 @@
|
||||
# V3 验收清单
|
||||
|
||||
> 业务事实源:[杜康好客-v3编码手册.md](./杜康好客-v3编码手册.md)
|
||||
> 自动化:`node scripts/smoke-v3.mjs`(Mock 环境)
|
||||
> 历史回归:`node scripts/smoke-prev1.mjs`(preV1 单链路)
|
||||
|
||||
## 必过主链路
|
||||
|
||||
| # | 验收项 | Owner | API / 页面 | 自动化 |
|
||||
|---|--------|-------|------------|--------|
|
||||
| 1 | C 端手机号登录 | jacy | `POST /auth/login/sms` · h5-user `/login` | smoke-v3 |
|
||||
| 2 | 首页郑州 4 商品 | jacy | `GET /catalog/products?cityCode=` · h5-user `/` | smoke-v3 |
|
||||
| 3 | 同城 1 瓶失败 | jacy | `POST /trade/orders/preview` | smoke-v3 |
|
||||
| 4 | 同城 2 瓶成功 | jacy | `POST /trade/orders` | smoke-v3 |
|
||||
| 5 | 跨城 5/6 瓶边界 | jacy | preview + create | smoke-v3 |
|
||||
| 6 | 支付后发权益 | jacy | pay + benefit | smoke-v3 |
|
||||
| 7 | 直接核销(无 couponId) | jacy + 刘京尧 | `POST /redeem/tokens` | smoke-v3 |
|
||||
| 8 | 单据核销 cap | jacy + 刘京尧 | `POST /redeem/tokens` + couponId | smoke-v3 |
|
||||
| 9 | 门店扫码确认 | 刘京尧 | `POST /shop/redeem/confirm` · h5-shop | smoke-v3 |
|
||||
| 10 | store_payout 生成 | jacy | settlement | smoke-v3 |
|
||||
| 11 | 关闭门店不可核销 | 刘京尧 | store status + redeem | smoke-v3 |
|
||||
| 12 | 录店审核后 C 端可见 | 刘京尧 + jacy | admin stores audit · h5-partner | 手动 |
|
||||
| 13 | 配送推进完成 | jacy | jobs / delivery callback | smoke-v3 |
|
||||
| 14 | 退款工单一致 | jacy | tickets + benefit void | smoke-v3 |
|
||||
| 15 | 门店 T+1 打款确认 | jacy | admin store-payouts | smoke-v3 |
|
||||
| 16 | 合伙人 T+30 账单 | jacy | admin partner-bills | smoke-v3 |
|
||||
|
||||
## 必过后台链路
|
||||
|
||||
| # | 验收项 | 页面 |
|
||||
|---|--------|------|
|
||||
| 1 | WebAdmin 登录 | admin-web `/login` |
|
||||
| 2 | 商品 CRUD | `/products` |
|
||||
| 3 | 审核门店 | `/stores` |
|
||||
| 4 | 订单与配送 | `/orders` · `/deliveries` |
|
||||
| 5 | 权益/核销/打款 | `/benefit/*` · `/redeem-records` · `/store-payouts` |
|
||||
| 6 | 退款/补发工单 | `/tickets` |
|
||||
| 7 | 第三方日志 | `/third-party-logs` |
|
||||
| 8 | 财务导出 | `/partner-bills` export |
|
||||
|
||||
## 环境矩阵
|
||||
|
||||
| 环境 | MOCK_SMS | MOCK_PAY | MOCK_DELIVERY_AUTO | 说明 |
|
||||
|------|----------|----------|-------------------|------|
|
||||
| 本地开发 | true | true | true | 固定验证码 123456 |
|
||||
| 测试沙箱 | false | false | false | 微信/配送沙箱 |
|
||||
| 生产 | false | false | false | 真实第三方 |
|
||||
|
||||
## 合伙人端交接(刘京尧)
|
||||
|
||||
Jacy 侧交付:`/partner/*` 后端 API、shared-types DTO、smoke 断言。
|
||||
刘京尧负责:`apps/h5-partner` 页面接入与验收。
|
||||
@@ -1,138 +0,0 @@
|
||||
# 杜康好客 · Cursor Agent 指引
|
||||
|
||||
> 本文件供 **人类与 Agent** 在编码前阅读。
|
||||
> **AI 自动加载入口**:[`AGENTS.md`](./AGENTS.md)(跨工具通用)
|
||||
> **Skills / 子 Agent**:`.cursor/skills/`、`.cursor/agents/`(Cursor 2.4+)
|
||||
> 与 [`skills.md`](./skills.md)、[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) 配合使用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 你是谁、要做什么
|
||||
|
||||
你是杜康好客 **preV1** 的实现 Agent(Mock 联调阶段)。目标:三端 H5 + NestJS 跑通购酒→发券→核销;架构与表结构对齐 **V2**。
|
||||
|
||||
**当前编码手册(preV1)**:[`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)
|
||||
**V2 完整规格(预留对齐)**:[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)
|
||||
|
||||
不要依赖 `doc/` 下分散旧文档(可能过时);不要臆造 PRD 未定义的业务规则。
|
||||
|
||||
---
|
||||
|
||||
## 2. 启动流程(每次会话)
|
||||
|
||||
1. 阅读 [`AGENTS.md`](./AGENTS.md) 确认 OWNER 边界与当前阶段(preV1)
|
||||
2. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 §
|
||||
3. 启用 Skill:`@dukang-coding`(通用)或 `@dukang-prev1` / `@dukang-task-card`(按需)
|
||||
4. 按 OWNER 选择子 Agent:`/owner-a-user-trade` 等(见 AGENTS.md §子 Agent)
|
||||
5. 阅读 [`conventions.md`](./conventions.md) 模块边界
|
||||
6. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ preV1 §9 或 V2 §七
|
||||
7. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq 小程序;HQ 用 admin-web)
|
||||
8. 一次只完成一个任务卡;PR 前可用 `/boundary-reviewer` 审查跨模块违规
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术栈(锁定)
|
||||
|
||||
| 层 | 选型 |
|
||||
|----|------|
|
||||
| 前端 | Taro 3 + React + TypeScript,Monorepo 四 App |
|
||||
| 后端 | Node 20 + NestJS 10 + Prisma 5 |
|
||||
| 数据 | MySQL 8 + Redis 7 + BullMQ |
|
||||
| 存储 | 阿里云 OSS → `common_resource` |
|
||||
| 协作 | 一个 `server/dukang-api`,模块 OWNER,禁止跨模块直写表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库 v3.1 要点(必记)
|
||||
|
||||
- **28 张表**,前缀:`user_` / `partner_` / `store_` / `hq_` / `common_` / `log_`
|
||||
- **公共抽象**:`common_resource`(OSS)、`common_event`(业务事件)、`common_ticket`(工单)、`log_third_party`(第三方)、`log_user_analytics`(埋点)
|
||||
- **C 端用户**:`user_user`,`phone` 唯一必填
|
||||
- **B 端三表**:`store_account` / `partner_account` / `hq_account`
|
||||
- **订单**:`user_order` 含商品快照(V1 单 SKU,无 `user_order_item`)
|
||||
- **配送**:`user_order_delivery` 与 `user_order` **1:1**
|
||||
- **支付**:无 `payments` 表 → `log_third_party` + `user_order.pay_*`
|
||||
- **核销码**:仅 Redis 5 分钟,无 DB 表
|
||||
- **权益流水**:`common_event(BENEFIT_LEDGER)`,`amount1`/`amount2` 仅此类使用
|
||||
|
||||
完整 DDL 见手册 **§五**。
|
||||
|
||||
---
|
||||
|
||||
## 5. API 与鉴权
|
||||
|
||||
- Base:`/api/v1`
|
||||
- JWT Payload:`actorType`(USER|STORE|PARTNER|HQ)+ `actorId` + `clientApp`
|
||||
- 请求头:`Authorization`、`X-Client-App`
|
||||
- 响应:`{ code, message, data }`
|
||||
- 完整路径见手册 **§六**
|
||||
|
||||
| clientApp | actorType | 账号表 |
|
||||
|-----------|-----------|--------|
|
||||
| USER_MINI | USER | user_user |
|
||||
| SHOP_H5 | STORE | store_account |
|
||||
| PARTNER_MINI | PARTNER | partner_account |
|
||||
| HQ_MINI | HQ | hq_account |
|
||||
|
||||
---
|
||||
|
||||
## 6. 模块 OWNER(2 人团队)
|
||||
|
||||
| 负责人 | Git 账号 | App(preV1 → V2) | Module |
|
||||
|--------|----------|-------------------|--------|
|
||||
| Jacy | jacy-dukang | h5-user、admin-web → mini-user/mini-hq | iam, trade, benefit, analytics, catalog, settlement, ops |
|
||||
| 刘景尧 | 刘景尧 | h5-partner、h5-shop → mini-partner | store, redeem |
|
||||
| Jacy(横切) | jacy-dukang | packages/*, callbacks/, jobs/, integrations/ | 基础设施 |
|
||||
|
||||
**禁止**:Module A 直写 Module B 的 Prisma 表;apps import server 源码。
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心业务规则(不可偏离)
|
||||
|
||||
```text
|
||||
权益发放额 = common_product_item.benefit_amount ?? price
|
||||
同城起购 2 瓶 / 跨城 6 瓶
|
||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
|
||||
C 端门店列表仅 status=OPEN
|
||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||
支付回调幂等 → 发券 → common_event(BENEFIT_LEDGER, GRANT)
|
||||
门店核销结算 60%,T+1;合伙人 T+30 账单
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 仓库目标结构(M0 优先)
|
||||
|
||||
```
|
||||
dukang/
|
||||
├── apps/{mini-user,mini-partner,mini-hq,h5-shop}/
|
||||
├── packages/{shared-types,domain,shared-ui,shared-utils}/
|
||||
├── server/dukang-api/{prisma,src/modules,callbacks,jobs}/
|
||||
├── pages/{user,shop,partner,hq}/ # 原型图(只读参照)
|
||||
├── 杜康好客-V2编码手册.md
|
||||
├── agent.md / skills.md / conventions.md
|
||||
└── deploy/docker-compose.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 完成定义(DoD)
|
||||
|
||||
- [ ] 任务卡验收项全部满足
|
||||
- [ ] 枚举/DTO 在 `packages/shared-types`
|
||||
- [ ] 纯规则在 `packages/domain`(含单测)
|
||||
- [ ] 未跨模块直写 Prisma 表
|
||||
- [ ] 改了 API/表结构则同步手册相关章节(或提 PR 说明)
|
||||
|
||||
---
|
||||
|
||||
## 10. 里程碑顺序
|
||||
|
||||
**M0** Monorepo 骨架 → **M1** 四端登录+开城+商品 → **M2** 下单支付+5 Tab 订单 → **M3** 权益核销 → **M4** 拓店审核+售后 → **M5** 结算 → **M6** 推广码+埋点 → 上线
|
||||
|
||||
从 **M0-INFRA-001** 开始,除非用户指定其他任务卡。
|
||||
|
||||
---
|
||||
|
||||
*编码时 @ [`AGENTS.md`](./AGENTS.md) 或 Skill `dukang-coding`;需求变更只改 `杜康好客-V2编码手册.md`。*
|
||||
@@ -1,65 +0,0 @@
|
||||
# 杜康好客 · 前端 Apps AGENTS.md
|
||||
|
||||
> 父级:[`../AGENTS.md`](../AGENTS.md) · 路由映射:[`../pages/ROUTE_MAP.md`](../pages/ROUTE_MAP.md)
|
||||
|
||||
## App 一览(preV1)
|
||||
|
||||
| App | 端口 | X-Client-App | 负责人 | 原型 |
|
||||
|-----|------|--------------|--------|------|
|
||||
| `h5-user` | 5173 | `USER_H5` | jacy-dukang | `pages/user/` |
|
||||
| `mini-user` | 5177 / weapp | `USER_MINI` | jacy-dukang | `pages/user/`(V3 目标形态) |
|
||||
| `h5-shop` | 5174 | `SHOP_H5` | 刘景尧 | `pages/shop/` |
|
||||
| `h5-partner` | 5175 | `PARTNER_H5` | 刘景尧 | `pages/partner/` |
|
||||
| `admin-web` | — | (Admin JWT) | jacy-dukang | preV1 内部 HQ 替代 |
|
||||
|
||||
V3 目标:C 端交付为微信小程序 `apps/mini-user`(当前与 `h5-user` 并行;功能以 H5 为过渡参考)。
|
||||
V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
|
||||
|
||||
## 前端硬规则
|
||||
|
||||
- 请求基址 `/api/v1`;Header:`Authorization` + `X-Client-App`
|
||||
- 类型从 `@dukang/shared-types` import,禁止复制枚举字符串
|
||||
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||
- UI 共享组件优先 `@dukang/shared-ui`
|
||||
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
||||
|
||||
## 新页面 workflow
|
||||
|
||||
1. 在 `pages/ROUTE_MAP.md` 查 Stitch screen ↔ route
|
||||
2. 对照 `pages/{端}/*/code.html` + `screen.png`(只读参照)
|
||||
3. 在对应 App 的 `src/pages/` 实现;API 封装放 `src/lib/api.ts`
|
||||
4. 登录态:各 App 自有 Context/Storage,不跨 App 共享
|
||||
|
||||
## 各 App 职责边界
|
||||
|
||||
### h5-user(jacy-dukang)
|
||||
|
||||
主链路:登录 → 首页/详情 → 下单 Mock 支付 → **3 Tab** 订单 → 权益 → 核销码 → 门店列表
|
||||
|
||||
**勿改**:门店核销确认 UI(属 h5-shop)、合伙人录店(属 h5-partner)
|
||||
|
||||
### h5-shop(刘景尧)
|
||||
|
||||
主链路:门店登录 → 首页 → 扫码/输入核销 → 确认 → 记录 → 营业状态
|
||||
|
||||
**勿改**:C 端出码页面(属 h5-user)
|
||||
|
||||
### h5-partner(刘景尧)
|
||||
|
||||
主链路:登录 → 工作台 → 录店 → 门店列表 → 辖区订单 → Mock 推进配送(preV1)
|
||||
|
||||
**勿改**:C 端商品/订单页、总部报表(admin-web / V2 mini-hq)
|
||||
|
||||
### admin-web(jacy-dukang,preV1 限定)
|
||||
|
||||
内部 Web 管理:开城、商品、订单、门店审核、结算查看。**不是** V2 总部小程序规格。
|
||||
|
||||
变更 admin-web 时勿假设与 `pages/hq/` 原型 1:1;V2 需另建 `mini-hq`。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
pnpm dev:user | dev:shop | dev:partner | dev:admin
|
||||
pnpm dev:mini-user # C 端小程序 H5 预览 :5177
|
||||
pnpm dev:mini-user:weapp # 微信开发者工具打开 apps/mini-user/dist
|
||||
```
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>杜康好客 · HQ 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@dukang/admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5175",
|
||||
"build": "vite build",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"antd": "^5.22.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^6.1.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { getToken } from './lib/api';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import OrdersPage from './pages/OrdersPage';
|
||||
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
||||
import StoresPage from './pages/StoresPage';
|
||||
import StoreRatingsPage from './pages/StoreRatingsPage';
|
||||
import StoreAccountsPage from './pages/StoreAccountsPage';
|
||||
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
||||
import PendingRedeemPage from './pages/PendingRedeemPage';
|
||||
import RedeemDebugPage from './pages/RedeemDebugPage';
|
||||
import DeliveriesPage from './pages/DeliveriesPage';
|
||||
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
||||
import HqAccountsPage from './pages/HqAccountsPage';
|
||||
import CitiesPage from './pages/CitiesPage';
|
||||
import CityPartnersPage from './pages/CityPartnersPage';
|
||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import StoreCategoriesPage from './pages/StoreCategoriesPage';
|
||||
import PromoCodesPage from './pages/PromoCodesPage';
|
||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
||||
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
|
||||
import ProductsPage from './pages/ProductsPage';
|
||||
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import StoreBillsPage from './pages/StoreBillsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import WineryBillsPage from './pages/WineryBillsPage';
|
||||
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||
import InvoicesPage from './pages/InvoicesPage';
|
||||
import UserLogsPage from './pages/UserLogsPage';
|
||||
import DomainEventsPage from './pages/DomainEventsPage';
|
||||
import HqLogsPage from './pages/HqLogsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
import StoreLogsPage from './pages/StoreLogsPage';
|
||||
import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import WecomBotsPage from './pages/WecomBotsPage';
|
||||
import LlmConfigsPage from './pages/LlmConfigsPage';
|
||||
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/promo-codes" element={<PromoCodesPage />} />
|
||||
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
|
||||
<Route index element={<PromoCodeDetailPage />} />
|
||||
<Route path="users" element={<PromoCodeUsersPage />} />
|
||||
</Route>
|
||||
<Route path="/wecom-bots" element={<WecomBotsPage />} />
|
||||
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
||||
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/store-package-audits" element={<StorePackageAuditsPage />} />
|
||||
<Route path="/store-ratings" element={<StoreRatingsPage />} />
|
||||
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/partners" element={<Navigate to="/city-partners" replace />} />
|
||||
<Route path="/cities" element={<CitiesPage />} />
|
||||
<Route path="/city-partners" element={<CityPartnersPage />} />
|
||||
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
|
||||
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
|
||||
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
||||
<Route
|
||||
path="/finance/store-withdrawals"
|
||||
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
|
||||
/>
|
||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route
|
||||
path="/store-withdrawals"
|
||||
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
|
||||
/>
|
||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
||||
<Route path="/logs/hq" element={<HqLogsPage />} />
|
||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/logs/domain-events" element={<DomainEventsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 左侧导航可滚动但不显示滚动条 */
|
||||
.admin-sider-menu-scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.admin-sider-menu-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* 后台内容行:不换行 + 超出省略
|
||||
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
|
||||
*/
|
||||
.admin-layout .ant-table-cell,
|
||||
.ant-drawer .ant-table-cell,
|
||||
.ant-modal .ant-table-cell,
|
||||
.admin-layout .ant-descriptions-item-content,
|
||||
.ant-drawer .ant-descriptions-item-content,
|
||||
.ant-modal .ant-descriptions-item-content {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
.admin-layout .ant-descriptions-item-content,
|
||||
.ant-drawer .ant-descriptions-item-content,
|
||||
.ant-modal .ant-descriptions-item-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 表头同样不换行 */
|
||||
.admin-layout .ant-table-thead > tr > th,
|
||||
.ant-drawer .ant-table-thead > tr > th,
|
||||
.ant-modal .ant-table-thead > tr > th {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 操作列、含交互控件的不截断 */
|
||||
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||
.ant-modal .ant-table-cell:has(.ant-btn),
|
||||
.admin-layout .ant-table-cell:has(.ant-space),
|
||||
.ant-drawer .ant-table-cell:has(.ant-space),
|
||||
.ant-modal .ant-table-cell:has(.ant-space) {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 代码块、表单区域保持原样 */
|
||||
.admin-layout pre,
|
||||
.ant-drawer pre,
|
||||
.ant-modal pre,
|
||||
.admin-layout .ant-form-item-control-input,
|
||||
.ant-drawer .ant-form-item-control-input,
|
||||
.ant-modal .ant-form-item-control-input {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
text-overflow: unset;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 表格单元格双行信息合并为单行省略 */
|
||||
.admin-cell-line {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.admin-cell-line-secondary {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-table-nowrap .ant-table-cell,
|
||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
type AdminCellLineProps = {
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
separator?: string;
|
||||
};
|
||||
|
||||
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
|
||||
export function AdminCellLine({
|
||||
primary,
|
||||
secondary,
|
||||
separator = ' · ',
|
||||
}: AdminCellLineProps) {
|
||||
const main = primary?.trim() || '—';
|
||||
const sub = secondary?.trim();
|
||||
const full = sub ? `${main}${separator}${sub}` : main;
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
className="admin-cell-line"
|
||||
ellipsis={{ tooltip: full }}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{main}
|
||||
{sub ? (
|
||||
<span className="admin-cell-line-secondary">
|
||||
{separator}
|
||||
{sub}
|
||||
</span>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Form, Input, InputNumber, Space, Typography, message } from 'antd';
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type PackageRow = StorePackageItemDto;
|
||||
|
||||
function emptyRow(index = 0): PackageRow {
|
||||
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index };
|
||||
}
|
||||
|
||||
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
||||
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
|
||||
.then((data) => {
|
||||
setItems(
|
||||
data.live?.length
|
||||
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
|
||||
: [emptyRow()],
|
||||
);
|
||||
})
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [storeId]);
|
||||
|
||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||
setCollapsed((prev) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const i = Number(k);
|
||||
if (i < index) next[i] = v;
|
||||
else if (i > index) next[i - 1] = v;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCollapse(index: number) {
|
||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const filled = items
|
||||
.map((item, index) => ({
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || null,
|
||||
otherNotes: item.otherNotes?.trim() || null,
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((item) => item.name || item.dishes || item.price);
|
||||
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const item = filled[i];
|
||||
if (!item.name) {
|
||||
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||
return;
|
||||
}
|
||||
if (!item.dishes) {
|
||||
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||
return;
|
||||
}
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await request(`/admin/stores/${storeId}/packages`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||
}),
|
||||
});
|
||||
message.success('套餐已保存并生效');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
||||
/>
|
||||
|
||||
{items.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
onClick={() => toggleCollapse(index)}
|
||||
style={{ paddingLeft: 0, height: 'auto' }}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{displayName}
|
||||
</Typography.Title>
|
||||
</Button>
|
||||
{items.length > 1 ? (
|
||||
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="如:套餐A"
|
||||
value={item.name}
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="元"
|
||||
placeholder="198"
|
||||
value={item.price === '' ? undefined : Number(item.price)}
|
||||
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
value={item.dishes}
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="节假日除外"
|
||||
value={item.usableTime || ''}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
||||
<Input
|
||||
placeholder="不可叠加"
|
||||
value={item.otherNotes || ''}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||
添加套餐
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
修改后点击下方按钮保存,C 端将立即展示生效套餐。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存套餐
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Cascader } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/cascader';
|
||||
import { PROVINCE_CITY_OPTIONS } from '../lib/china-region';
|
||||
|
||||
type ChinaProvinceCityCascaderProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function ChinaProvinceCityCascader({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = '请选择省 / 市',
|
||||
}: ChinaProvinceCityCascaderProps) {
|
||||
return (
|
||||
<Cascader
|
||||
options={PROVINCE_CITY_OPTIONS as DefaultOptionType[]}
|
||||
value={value}
|
||||
onChange={(codes) => onChange?.((codes ?? []) as string[])}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
showSearch={{
|
||||
filter: (input, path) =>
|
||||
path.some((option) =>
|
||||
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
|
||||
),
|
||||
}}
|
||||
changeOnSelect={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Cascader } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/cascader';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
|
||||
type ChinaRegionCascaderProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function ChinaRegionCascader({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = '请选择省 / 市 / 区县',
|
||||
}: ChinaRegionCascaderProps) {
|
||||
return (
|
||||
<Cascader
|
||||
options={CHINA_REGION_OPTIONS as DefaultOptionType[]}
|
||||
value={value}
|
||||
onChange={(codes) => onChange?.((codes ?? []) as string[])}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
showSearch={{
|
||||
filter: (input, path) =>
|
||||
path.some((option) =>
|
||||
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
|
||||
),
|
||||
}}
|
||||
changeOnSelect={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { getDistrictOptionsByCityCode } from '../lib/china-region';
|
||||
|
||||
type Props = {
|
||||
cityCode?: string | null;
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
|
||||
export default function CityDistrictMultiSelect({
|
||||
cityCode,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
|
||||
const ready = Boolean(cityCode) && options.length > 0;
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || !ready}
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
|
||||
}
|
||||
options={options}
|
||||
style={{ width: '100%' }}
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = PartnerRow & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
cityCode?: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(
|
||||
orderPercent: number,
|
||||
redeemPercent: number,
|
||||
maxRate: number,
|
||||
): string | null {
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({
|
||||
cityId,
|
||||
cityCode,
|
||||
maxPartnerCommissionRate = 0.05,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
|
||||
const loadPartners = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||
setPartners(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, [loadPartners]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
if (detail) void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 120,
|
||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
cityId,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||
</Typography.Text>
|
||||
<Form.Item label="管仓仓库">
|
||||
<Input
|
||||
disabled
|
||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
setSubEditId(row.id);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}}
|
||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
||||
if (!subEditId || !detail) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Space } from 'antd';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
parseMiniHomeBanners,
|
||||
serializeMiniHomeBanners,
|
||||
} from '@dukang/shared-types';
|
||||
import OssUpload from './OssUpload';
|
||||
|
||||
const MAX_BANNERS = 8;
|
||||
|
||||
/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */
|
||||
function parseBannersForEdit(raw?: string | null): string[] {
|
||||
if (!raw?.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw.trim()) as unknown;
|
||||
if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw);
|
||||
return parsed
|
||||
.filter((u): u is string => typeof u === 'string')
|
||||
.map((u) => u.trim())
|
||||
.slice(0, MAX_BANNERS);
|
||||
} catch {
|
||||
return parseMiniHomeBanners(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer) */
|
||||
export function ConfigImageField({
|
||||
value,
|
||||
onChange,
|
||||
bizType = 'footer',
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
bizType?: string;
|
||||
}) {
|
||||
return (
|
||||
<OssUpload
|
||||
bizType={bizType}
|
||||
mediaType="IMAGE"
|
||||
value={value ?? ''}
|
||||
onChange={(url) => onChange?.(url ?? '')}
|
||||
placeholder="上传或粘贴图片 URL"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper) */
|
||||
export function ConfigImageListField({
|
||||
value,
|
||||
onChange,
|
||||
bizType = 'swiper',
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (json: string) => void;
|
||||
bizType?: string;
|
||||
}) {
|
||||
const [urls, setUrls] = useState<string[]>(() => parseBannersForEdit(value));
|
||||
|
||||
useEffect(() => {
|
||||
setUrls(parseBannersForEdit(value));
|
||||
}, [value]);
|
||||
|
||||
/** 本地可含空位;写入 Form 时去掉空串 */
|
||||
function commit(next: string[]) {
|
||||
const clipped = next.slice(0, MAX_BANNERS);
|
||||
setUrls(clipped);
|
||||
onChange?.(serializeMiniHomeBanners(clipped));
|
||||
}
|
||||
|
||||
function updateAt(index: number, url: string) {
|
||||
const next = [...urls];
|
||||
next[index] = url;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
commit(urls.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= urls.length) return;
|
||||
const next = [...urls];
|
||||
const tmp = next[index];
|
||||
next[index] = next[target];
|
||||
next[target] = tmp;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (urls.length >= MAX_BANNERS) return;
|
||||
// 只加本地空位,避免 serialize 过滤空串导致「点击无反应」
|
||||
setUrls((prev) => [...prev, '']);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{urls.map((url, index) => (
|
||||
<Space key={`banner-${index}`} align="start" style={{ width: '100%' }} wrap>
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<OssUpload
|
||||
bizType={bizType}
|
||||
mediaType="IMAGE"
|
||||
value={url}
|
||||
onChange={(u) => updateAt(index, u)}
|
||||
placeholder="上传或粘贴图片 URL"
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={index === urls.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
/>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
|
||||
</Space>
|
||||
</Space>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
disabled={urls.length >= MAX_BANNERS}
|
||||
onClick={add}
|
||||
>
|
||||
添加轮播图({urls.length}/{MAX_BANNERS})
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Button, Form, Space, Typography } from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import OssUpload from './OssUpload';
|
||||
|
||||
type Props = {
|
||||
name?: string;
|
||||
label: string;
|
||||
bizType?: string;
|
||||
/** 最多可添加张数;不传则不限制 */
|
||||
maxCount?: number;
|
||||
};
|
||||
|
||||
export default function DetailImageUrlList({
|
||||
name = 'detailImageUrls',
|
||||
label,
|
||||
bizType = 'DETAIL',
|
||||
maxCount,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
{maxCount != null && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
最多 {maxCount} 张{label}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Form.List name={name}>
|
||||
{(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={bizType} mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
{(!maxCount || fields.length < maxCount) && (
|
||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
||||
添加{label}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Image, Input, Space, Upload, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||
|
||||
type OssUploadProps = {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
onUploaded?: (result: UploadFileResult) => void;
|
||||
bizType: string;
|
||||
mediaType?: OssMediaType;
|
||||
accept?: string;
|
||||
maxSizeMb?: number;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
export default function OssUpload({
|
||||
value,
|
||||
onChange,
|
||||
onUploaded,
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
accept,
|
||||
maxSizeMb = DEFAULT_MAX_MB,
|
||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||
}: OssUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||
|
||||
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||
const raw = file as File;
|
||||
if (raw.size > maxSizeMb * 1024 * 1024) {
|
||||
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
onUploaded?.(result);
|
||||
message.success('上传成功');
|
||||
onSuccess?.(result);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('上传失败');
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{value && mediaType === 'IMAGE' && (
|
||||
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
)}
|
||||
{value && mediaType === 'VIDEO' && (
|
||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||
)}
|
||||
<Space wrap>
|
||||
<Upload
|
||||
accept={resolvedAccept}
|
||||
showUploadList={false}
|
||||
customRequest={customRequest}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading}>
|
||||
{mediaType === 'VIDEO' ? '上传视频' : mediaType === 'FILE' ? '上传文件' : '上传图片'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PartnerSubAccountRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
function formatPermissions(permissions?: string[]) {
|
||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subs: PartnerSubAccountRow[];
|
||||
onAdd: () => void;
|
||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||
onDelete: (subId: string) => void;
|
||||
};
|
||||
|
||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||
</Typography.Text>
|
||||
<Button size="small" type="primary" onClick={onAdd}>
|
||||
添加子账号
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={subs}
|
||||
locale={{ emptyText: '暂无子账号' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Card, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import {
|
||||
getProductDetailTemplate,
|
||||
mapDtoToProductDetailTemplate,
|
||||
type ProductDetailTemplate,
|
||||
type ProductDetailTemplateDto,
|
||||
} from '../lib/product-detail-templates';
|
||||
|
||||
type Props = {
|
||||
form: FormInstance;
|
||||
/** 当前商品香型,用于推荐匹配模板 */
|
||||
aromaType?: string;
|
||||
};
|
||||
|
||||
function hasDetailContent(form: FormInstance) {
|
||||
const storyTitle = form.getFieldValue('storyTitle') as string | undefined;
|
||||
const storyText = form.getFieldValue('storyText') as string | undefined;
|
||||
const features = form.getFieldValue('features') as Array<{ title?: string; desc?: string }> | undefined;
|
||||
const detailImageUrls = form.getFieldValue('detailImageUrls') as string[] | undefined;
|
||||
const hasFeatures = (features ?? []).some((f) => f?.title?.trim() || f?.desc?.trim());
|
||||
const hasImages = (detailImageUrls ?? []).some((u) => u?.trim());
|
||||
return Boolean(storyTitle?.trim() || storyText?.trim() || hasFeatures || hasImages);
|
||||
}
|
||||
|
||||
function applyTemplate(form: FormInstance, template: ProductDetailTemplate) {
|
||||
const { content } = template;
|
||||
const detailImageUrls = content.detailImageUrls?.length
|
||||
? [...content.detailImageUrls]
|
||||
: [''];
|
||||
form.setFieldsValue({
|
||||
storyTitle: content.storyTitle ?? '',
|
||||
storyText: content.storyText ?? '',
|
||||
detailImageUrls,
|
||||
features:
|
||||
content.features && content.features.length > 0
|
||||
? content.features.map((f) => ({ ...f }))
|
||||
: [{ icon: 'star', title: '', desc: '' }],
|
||||
});
|
||||
const imageHint = detailImageUrls.filter(Boolean).length;
|
||||
message.success(
|
||||
imageHint > 0
|
||||
? `已应用模板「${template.label}」(含 ${imageHint} 张详情图,可逐张修改)`
|
||||
: `已应用模板「${template.label}」`,
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductDetailTemplatePicker({ form, aromaType }: Props) {
|
||||
const [templates, setTemplates] = useState<ProductDetailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
request<Paginated<ProductDetailTemplateDto>>(
|
||||
'/admin/product-detail-templates?status=ACTIVE&pageSize=100',
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const mapped = res.items.map(mapDtoToProductDetailTemplate);
|
||||
setTemplates(mapped);
|
||||
const defaultId =
|
||||
(aromaType && mapped.find((t) => t.aromaType === aromaType)?.id) ||
|
||||
mapped.find((t) => t.code === 'dukang-classic')?.id ||
|
||||
mapped[0]?.id;
|
||||
setSelectedId(defaultId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) message.error('加载详情模板失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [aromaType]);
|
||||
|
||||
const selected = getProductDetailTemplate(templates, selectedId ?? '');
|
||||
|
||||
function doApply() {
|
||||
if (!selected) return;
|
||||
applyTemplate(form, selected);
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
if (!selected) return;
|
||||
if (hasDetailContent(form)) {
|
||||
return;
|
||||
}
|
||||
doApply();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||
<Spin size="small" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">暂无可用模板,请先在「详情模板」菜单中创建。</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
选择模板可一键填充详情长图、故事与卖点;套用后可在下方逐张替换图片。
|
||||
</Typography.Paragraph>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择模板"
|
||||
value={selectedId}
|
||||
onChange={setSelectedId}
|
||||
options={templates.map((t) => ({
|
||||
value: t.id,
|
||||
label: t.label,
|
||||
}))}
|
||||
/>
|
||||
{selected && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={selected.description}
|
||||
description={
|
||||
<div style={{ fontSize: 12 }}>
|
||||
{selected.content.storyTitle && (
|
||||
<div><strong>标题:</strong>{selected.content.storyTitle}</div>
|
||||
)}
|
||||
{(selected.content.detailImageUrls?.length ?? 0) > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
详情图:<strong>{selected.content.detailImageUrls?.length}</strong> 张(套用后可逐张修改)
|
||||
</div>
|
||||
)}
|
||||
{(selected.content.features?.length ?? 0) > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
卖点:{selected.content.features?.map((f) => f.title).join('、')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Space>
|
||||
{hasDetailContent(form) ? (
|
||||
<Popconfirm
|
||||
title="将覆盖当前详情图、故事与卖点,是否继续?"
|
||||
onConfirm={doApply}
|
||||
okText="覆盖应用"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="primary">应用模板</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button type="primary" onClick={handleApply} disabled={!selected}>
|
||||
应用模板
|
||||
</Button>
|
||||
)}
|
||||
{aromaType && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const matched = templates.find((t) => t.aromaType === aromaType);
|
||||
if (matched) {
|
||||
setSelectedId(matched.id);
|
||||
if (!hasDetailContent(form)) {
|
||||
applyTemplate(form, matched);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
按香型推荐
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
QRCode,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type {
|
||||
HqProxyOrderCreateRequest,
|
||||
PartnerProxyDeliveryMode,
|
||||
PartnerProxyOrderOptions,
|
||||
PartnerProxyOrderPreviewResult,
|
||||
ProxyOrderCreateResponse,
|
||||
ProxyOrderPayResponse,
|
||||
} from '@dukang/shared-types';
|
||||
import ChinaRegionCascader from './ChinaRegionCascader';
|
||||
import { parseRegionCodes } from '../lib/china-region';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type ProxyOrderModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (order: { id: string; orderNo: string }) => void;
|
||||
};
|
||||
|
||||
type PayStatus = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
payStatus: string;
|
||||
};
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) {
|
||||
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [receiverName, setReceiverName] = useState('');
|
||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||
const [addressDetail, setAddressDetail] = useState('');
|
||||
const [productId, setProductId] = useState<string>();
|
||||
const [quantity, setQuantity] = useState(2);
|
||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||
const [autoReceive, setAutoReceive] = useState(false);
|
||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [step, setStep] = useState<'form' | 'pay'>('form');
|
||||
const [created, setCreated] = useState<ProxyOrderCreateResponse | null>(null);
|
||||
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||||
const [payLoading, setPayLoading] = useState(false);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
||||
|
||||
function stopPoll() {
|
||||
if (pollRef.current != null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoadingOptions(true);
|
||||
request<PartnerProxyOrderOptions>('/admin/proxy-orders/options')
|
||||
.then((data) => {
|
||||
setOptions(data);
|
||||
if (data.products[0]) setProductId(data.products[0].id);
|
||||
})
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoadingOptions(false));
|
||||
}, [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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedProduct) return;
|
||||
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
|
||||
setDeliveryMode('ON_SITE_PICKUP');
|
||||
return;
|
||||
}
|
||||
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
|
||||
setDeliveryMode('ADDRESS');
|
||||
}
|
||||
}, [open, selectedProduct, allowOnline, allowOnSite, deliveryMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !productId || quantity < 1 || step !== 'form') {
|
||||
if (step === 'form') setPreview(null);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setPreviewLoading(true);
|
||||
request<PartnerProxyOrderPreviewResult>('/admin/proxy-orders/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
quantity,
|
||||
deliveryMode,
|
||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||
}),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(() => setPreview(null))
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||
|
||||
useEffect(() => () => stopPoll(), []);
|
||||
|
||||
function resetForm() {
|
||||
stopPoll();
|
||||
setPhone('');
|
||||
setReceiverName('');
|
||||
setRegionCodes([]);
|
||||
setAddressDetail('');
|
||||
setQuantity(2);
|
||||
setPromoCodeId(undefined);
|
||||
setDeliveryMode('ADDRESS');
|
||||
setAutoReceive(false);
|
||||
setPreview(null);
|
||||
setProductId(options?.products[0]?.id);
|
||||
setStep('form');
|
||||
setCreated(null);
|
||||
setCodeUrl(null);
|
||||
setPayLoading(false);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||
if (!productId) return '请选择商品';
|
||||
if (deliveryMode === 'ADDRESS') {
|
||||
if (!allowOnline) return '该商品不支持线上购买';
|
||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||
if (!addressDetail.trim()) return '请填写详细地址';
|
||||
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||
} else if (!allowOnSite) {
|
||||
return '该商品不支持现场提货';
|
||||
}
|
||||
if (!preview) return '请等待费用计算完成';
|
||||
return null;
|
||||
}
|
||||
|
||||
function startPoll(orderId: string) {
|
||||
stopPoll();
|
||||
pollRef.current = window.setInterval(() => {
|
||||
void request<PayStatus>(`/admin/proxy-orders/${orderId}/pay-status`)
|
||||
.then((st) => {
|
||||
if (st.payStatus === 'PAID') {
|
||||
stopPoll();
|
||||
message.success(`支付成功:${st.orderNo}`);
|
||||
const payload = { id: st.id, orderNo: st.orderNo };
|
||||
resetForm();
|
||||
onSuccess(payload);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const err = validateForm();
|
||||
if (err) {
|
||||
message.warning(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: HqProxyOrderCreateRequest = {
|
||||
phone: phone.trim(),
|
||||
deliveryMode,
|
||||
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
||||
receiverName: receiverName.trim() || undefined,
|
||||
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
|
||||
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
|
||||
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
|
||||
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
|
||||
productId: productId!,
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const order = await request<ProxyOrderCreateResponse>('/admin/proxy-orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setCreated(order);
|
||||
setStep('pay');
|
||||
setPayLoading(true);
|
||||
const pay = await request<ProxyOrderPayResponse>(`/admin/proxy-orders/${order.id}/pay`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
||||
});
|
||||
setCodeUrl(pay.codeUrl ?? null);
|
||||
startPoll(order.id);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '下单失败');
|
||||
setStep('form');
|
||||
setCreated(null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setPayLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const deliveryLabel =
|
||||
preview?.deliveryType === 'ON_SITE_PICKUP'
|
||||
? '现场提货'
|
||||
: preview?.deliveryType === 'CROSS_CITY'
|
||||
? '跨城配送'
|
||||
: '同城配送';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={step === 'pay' ? '代下单 · 待支付' : '代下单'}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
footer={
|
||||
step === 'pay' ? (
|
||||
<Space>
|
||||
<Button onClick={handleClose}>关闭</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
<Button type="primary" loading={submitting || loadingOptions} onClick={() => void submit()}>
|
||||
提交并收款
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{step === 'pay' && created ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, textAlign: 'left' }}
|
||||
message={`订单 ${created.orderNo} 待支付 ¥${fmtMoney(created.payAmount)},请扫码支付`}
|
||||
description={
|
||||
created.deliveryType === 'ON_SITE_PICKUP'
|
||||
? '支付完成后订单即为已完成,并发放好客权益'
|
||||
: '支付完成后将进入待发货,由总部履约发货'
|
||||
}
|
||||
/>
|
||||
{payLoading && !codeUrl ? (
|
||||
<Typography.Text type="secondary">正在生成收款码…</Typography.Text>
|
||||
) : codeUrl ? (
|
||||
<Space direction="vertical" size={12} align="center">
|
||||
<QRCode value={codeUrl} size={200} />
|
||||
<Typography.Text type="secondary">请使用微信扫一扫完成支付,成功后自动关闭</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
||||
{codeUrl}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="danger">收款码生成失败,请关闭后重试</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="提交后生成微信收款码,支付成功后发放好客权益;配送单进入待发货"
|
||||
/>
|
||||
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="用户手机号" required>
|
||||
<Input
|
||||
placeholder="11 位手机号"
|
||||
value={phone}
|
||||
maxLength={11}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="酒品" required>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={loadingOptions}
|
||||
placeholder="选择商品"
|
||||
value={productId}
|
||||
onChange={setProductId}
|
||||
options={(options?.products ?? []).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="数量" required>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{(options?.promoCodes.length ?? 0) > 0 ? (
|
||||
<Form.Item label="绑定推广码(选填)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="不绑定"
|
||||
value={promoCodeId}
|
||||
onChange={setPromoCodeId}
|
||||
options={(options?.promoCodes ?? []).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.code} · ${p.name}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<Form.Item label="履约方式" required>
|
||||
{allowOnline && allowOnSite ? (
|
||||
<Radio.Group
|
||||
value={deliveryMode}
|
||||
onChange={(e) => {
|
||||
setDeliveryMode(e.target.value);
|
||||
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'ADDRESS', label: '配送到址' },
|
||||
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
{!allowOnline && allowOnSite
|
||||
? '该商品仅支持现场提货'
|
||||
: allowOnline && !allowOnSite
|
||||
? allowCrossCity
|
||||
? '该商品仅支持配送到址(含跨城)'
|
||||
: '该商品仅支持配送到址(不可跨城)'
|
||||
: '该商品暂无可选履约方式'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
{deliveryMode === 'ADDRESS' && allowOnline ? (
|
||||
<>
|
||||
<Form.Item label="收货人(选填)">
|
||||
<Input
|
||||
placeholder="默认:用户+手机尾号"
|
||||
value={receiverName}
|
||||
maxLength={32}
|
||||
onChange={(e) => setReceiverName(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="省市区" required>
|
||||
<ChinaRegionCascader value={regionCodes} onChange={setRegionCodes} />
|
||||
</Form.Item>
|
||||
<Form.Item label="详细地址" required>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="街道门牌等"
|
||||
value={addressDetail}
|
||||
maxLength={256}
|
||||
onChange={(e) => setAddressDetail(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)}>
|
||||
同意自动收货(配送到址必选)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>
|
||||
<Typography.Text type="secondary">费用预览</Typography.Text>
|
||||
{previewLoading ? (
|
||||
<div>计算中…</div>
|
||||
) : preview ? (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 8 }}>
|
||||
<div>履约:{deliveryLabel}</div>
|
||||
<div>商品金额:¥{fmtMoney(preview.productAmount)}</div>
|
||||
<div>实付:¥{fmtMoney(preview.payAmount)}</div>
|
||||
<div>好客权益:¥{fmtMoney(preview.benefitAmount)}</div>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text type="secondary">请完善商品与数量</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
placeToPicked,
|
||||
type LbsPlaceItem,
|
||||
type TencentPickedLocation,
|
||||
} from '../lib/tencentLocPicker';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (loc: TencentPickedLocation) => void;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
/** 城市名,提升搜索相关性 */
|
||||
region?: string | null;
|
||||
};
|
||||
|
||||
function hasCoords(lat: unknown, lng: unknown): lat is number {
|
||||
const a = typeof lat === 'number' ? lat : Number(lat);
|
||||
const b = typeof lng === 'number' ? lng : Number(lng);
|
||||
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
|
||||
}
|
||||
|
||||
export default function TencentLocPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onPick,
|
||||
latitude,
|
||||
longitude,
|
||||
region,
|
||||
}: Props) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [items, setItems] = useState<LbsPlaceItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
|
||||
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
|
||||
const seqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setKeyword('');
|
||||
setItems([]);
|
||||
setPending(null);
|
||||
setHint('输入地点名称搜索,或加载附近地点');
|
||||
return;
|
||||
}
|
||||
const lat = latitude != null ? Number(latitude) : NaN;
|
||||
const lng = longitude != null ? Number(longitude) : NaN;
|
||||
if (hasCoords(lat, lng)) {
|
||||
void loadNearby(lat, lng);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
async function loadNearby(lat: number, lng: number) {
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
setHint('正在加载附近地点…');
|
||||
try {
|
||||
const res = await request<{ items: LbsPlaceItem[] }>(
|
||||
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
|
||||
);
|
||||
if (seq !== seqRef.current) return;
|
||||
setItems(res.items ?? []);
|
||||
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : '加载附近地点失败';
|
||||
setItems([]);
|
||||
setHint(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(q: string) {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
message.warning('请输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
setHint('搜索中…');
|
||||
try {
|
||||
const params = new URLSearchParams({ keyword: trimmed });
|
||||
if (region?.trim()) params.set('region', region.trim());
|
||||
const lat = latitude != null ? Number(latitude) : NaN;
|
||||
const lng = longitude != null ? Number(longitude) : NaN;
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
params.set('lat', String(lat));
|
||||
params.set('lng', String(lng));
|
||||
}
|
||||
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
|
||||
if (seq !== seqRef.current) return;
|
||||
setItems(res.items ?? []);
|
||||
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : '搜索失败';
|
||||
setItems([]);
|
||||
setHint(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function useBrowserLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
message.error('当前浏览器不支持定位');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
|
||||
void loadNearby(lat, lng);
|
||||
},
|
||||
() => {
|
||||
setLoading(false);
|
||||
message.error('定位失败,请检查浏览器定位权限');
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 12000 },
|
||||
);
|
||||
}
|
||||
|
||||
function confirmPick() {
|
||||
if (!pending) {
|
||||
message.warning('请先从列表中选择一个地点');
|
||||
return;
|
||||
}
|
||||
onPick(pending);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="地图选点(腾讯位置服务)"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
|
||||
{pending
|
||||
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
|
||||
pending.name ? ` · ${pending.name}` : ''
|
||||
}`
|
||||
: '搜索或选择附近地点后确认'}
|
||||
</Typography.Text>
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" disabled={!pending} onClick={confirmPick}>
|
||||
确认选点
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="输入小区 / 写字楼 / 门店名称"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => void runSearch(keyword)}
|
||||
/>
|
||||
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space wrap>
|
||||
<Button onClick={useBrowserLocation} disabled={loading}>
|
||||
定位当前位置
|
||||
</Button>
|
||||
<Typography.Text type="secondary">{hint}</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||
{loading && !items.length ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin tip="加载中…" />
|
||||
</div>
|
||||
) : items.length ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
renderItem={(item) => {
|
||||
const active =
|
||||
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
|
||||
paddingInline: 12,
|
||||
}}
|
||||
onClick={() => setPending(placeToPicked(item))}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={item.title}
|
||||
description={
|
||||
<span>
|
||||
{item.address}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty style={{ marginTop: 80 }} description={hint} />
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
RobotOutlined,
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
ShoppingOutlined,
|
||||
ShopOutlined,
|
||||
TeamOutlined,
|
||||
GiftOutlined,
|
||||
CarOutlined,
|
||||
SafetyOutlined,
|
||||
LogoutOutlined,
|
||||
CloudUploadOutlined,
|
||||
FileTextOutlined,
|
||||
LockOutlined,
|
||||
SettingOutlined,
|
||||
AccountBookOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
type MenuItem = NonNullable<MenuProps['items']>[number];
|
||||
|
||||
const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
||||
{
|
||||
key: 'products-group',
|
||||
icon: <ShoppingOutlined />,
|
||||
label: '商品',
|
||||
children: [
|
||||
{ key: '/products', label: '商品列表' },
|
||||
{ key: '/product-detail-templates', label: '详情模板' },
|
||||
],
|
||||
},
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
|
||||
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||
{
|
||||
key: 'stores-group',
|
||||
icon: <ShopOutlined />,
|
||||
label: '门店',
|
||||
children: [
|
||||
{ key: '/stores', label: '门店列表' },
|
||||
{ key: '/store-package-audits', label: '套餐审核' },
|
||||
{ key: '/store-ratings', label: '门店评价' },
|
||||
{ key: '/store-categories', label: '门店分类' },
|
||||
{ key: '/store-accounts', label: '门店账户' },
|
||||
{ key: '/store-media', label: '门店资源' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'partners-group',
|
||||
icon: <TeamOutlined />,
|
||||
label: '开城',
|
||||
children: [
|
||||
{ key: '/cities', label: '城市' },
|
||||
{ key: '/city-partners', label: '城市合伙人' },
|
||||
{ key: '/city-warehouses', label: '仓库' },
|
||||
{ key: '/fulfillment-providers', label: '仓配管理' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'finance-group',
|
||||
icon: <AccountBookOutlined />,
|
||||
label: '财务',
|
||||
children: [
|
||||
{ key: '/finance/store-bills', label: '门店账单' },
|
||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'benefit-group',
|
||||
icon: <GiftOutlined />,
|
||||
label: '好客权益',
|
||||
children: [
|
||||
{ key: '/benefit/coupons', label: '权益券' },
|
||||
{ key: '/benefit/ledgers', label: '流水' },
|
||||
{ key: '/redeem-records', label: '核销记录' },
|
||||
{ key: '/redeem-pending', label: '待处理核销' },
|
||||
{ key: '/redeem/debug', label: '核销调试' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'deliveries-group',
|
||||
icon: <CarOutlined />,
|
||||
label: '配送单',
|
||||
children: [
|
||||
{ key: '/deliveries', label: '配送单列表' },
|
||||
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tickets-group',
|
||||
icon: <CarOutlined />,
|
||||
label: '工单',
|
||||
children: [
|
||||
{ key: '/tickets', label: '工单中心' },
|
||||
{ key: '/tickets/support', label: '技术支持' },
|
||||
],
|
||||
},
|
||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||
{
|
||||
key: 'logs-group',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '日志',
|
||||
children: [
|
||||
{ key: '/logs/users', label: '用户日志' },
|
||||
{ key: '/logs/stores', label: '商户日志' },
|
||||
{ key: '/logs/partners', label: '合伙人日志' },
|
||||
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
||||
{ key: '/logs/third-party', label: '第三方日志' },
|
||||
{ key: '/logs/domain-events', label: '领域事件' },
|
||||
],
|
||||
},
|
||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
|
||||
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
if (key === 'tickets-group') {
|
||||
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||
}
|
||||
const map: Record<string, string | 'system_settings_any'> = {
|
||||
'/': 'dashboard',
|
||||
'/users': 'users',
|
||||
'/wechat-bindings': 'wechat_bindings',
|
||||
'products-group': 'products',
|
||||
'/products': 'products',
|
||||
'/product-detail-templates': 'products',
|
||||
'/orders': 'orders',
|
||||
'/promo-codes': 'promo_codes',
|
||||
'/wecom-bots': 'wecom_bots',
|
||||
'/llm-configs': 'llm_configs',
|
||||
'/knowledge-bases': 'knowledge_bases',
|
||||
'stores-group': 'stores',
|
||||
'/stores': 'stores',
|
||||
'/store-package-audits': 'stores',
|
||||
'/store-ratings': 'stores',
|
||||
'/store-categories': 'stores',
|
||||
'/store-accounts': 'stores',
|
||||
'/store-media': 'stores',
|
||||
'partners-group': 'partners',
|
||||
'/cities': 'partners',
|
||||
'/city-partners': 'partners',
|
||||
'/city-warehouses': 'partners',
|
||||
'/fulfillment-providers': 'partners',
|
||||
'finance-group': 'finance',
|
||||
'/finance/store-bills': 'finance',
|
||||
'/finance/store-withdrawals': 'finance',
|
||||
'/finance/partner-bills': 'finance',
|
||||
'/finance/winery-bills': 'finance',
|
||||
'/finance/logistics-bills': 'finance',
|
||||
'benefit-group': 'benefit',
|
||||
'/benefit/coupons': 'benefit',
|
||||
'/benefit/ledgers': 'benefit',
|
||||
'/redeem-records': 'benefit',
|
||||
'/redeem-pending': 'benefit',
|
||||
'/redeem/debug': 'benefit',
|
||||
'deliveries-group': 'deliveries',
|
||||
'/deliveries': 'deliveries',
|
||||
'/deliveries/xiaofeixia': 'deliveries',
|
||||
'/tickets': 'tickets',
|
||||
'/tickets/support': 'tech_support',
|
||||
'/invoices': 'invoices',
|
||||
'/resources': 'resources',
|
||||
'logs-group': 'logs',
|
||||
'/logs/users': 'logs',
|
||||
'/logs/stores': 'logs',
|
||||
'/logs/partners': 'logs',
|
||||
'/logs/hq': 'logs',
|
||||
'/logs/third-party': 'logs',
|
||||
'/logs/domain-events': 'logs',
|
||||
'/hq-permissions': 'hq_permissions',
|
||||
'/system-settings': 'system_settings_any',
|
||||
'/hq-accounts': 'hq_accounts',
|
||||
};
|
||||
const need = map[key];
|
||||
if (!need) return true;
|
||||
if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys);
|
||||
return permissionKeys.includes(need);
|
||||
}
|
||||
|
||||
function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] {
|
||||
if (!items) return items;
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||
const key = String(item.key);
|
||||
if ('children' in item && Array.isArray(item.children)) {
|
||||
if (!menuAllowed(key, permissionKeys)) return null;
|
||||
const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys);
|
||||
if (!children?.length) return null;
|
||||
return { ...item, children } as MenuItem;
|
||||
}
|
||||
return menuAllowed(key, permissionKeys) ? item : null;
|
||||
})
|
||||
.filter(Boolean) as MenuProps['items'];
|
||||
}
|
||||
|
||||
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
return bindAdminEllipsisTitle();
|
||||
}, []);
|
||||
|
||||
function logout() {
|
||||
clearAuth();
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
const selectedKey = location.pathname.startsWith('/promo-codes')
|
||||
? '/promo-codes'
|
||||
: location.pathname;
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
if (!profile) return MENU_ITEMS;
|
||||
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
||||
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
{IS_STAGING ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1100,
|
||||
background: '#d48806',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
lineHeight: '28px',
|
||||
}}
|
||||
>
|
||||
测试环境 · staging · 数据与生产隔离 · Mock 开启
|
||||
</div>
|
||||
) : null}
|
||||
<Sider
|
||||
breakpoint="lg"
|
||||
collapsedWidth={64}
|
||||
theme="dark"
|
||||
width={220}
|
||||
style={{ height: '100vh', overflow: 'hidden', marginTop: IS_STAGING ? 28 : 0 }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: IS_STAGING ? 'calc(100vh - 28px)' : '100vh' }}>
|
||||
<div style={{ flexShrink: 0, padding: '16px', color: '#fff', fontWeight: 600 }}>
|
||||
杜康 HQ{IS_STAGING ? ' · 测试' : ''}
|
||||
</div>
|
||||
<div className="admin-sider-menu-scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultOpenKeys={[]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('/')) navigate(key);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout
|
||||
style={{
|
||||
height: IS_STAGING ? 'calc(100vh - 28px)' : '100vh',
|
||||
marginTop: IS_STAGING ? 28 : 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
管理后台
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<span>{profile?.name || '—'}</span>
|
||||
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
|
||||
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
||||
退出
|
||||
</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="admin-layout"
|
||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||
>
|
||||
<Content style={{ margin: 24 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</div>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
export const apiBase = '/api/v1';
|
||||
export const CLIENT_APP = 'HQ_WEB';
|
||||
|
||||
export type HqProfile = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
adminRole: string;
|
||||
status: string;
|
||||
permissionKeys?: string[];
|
||||
};
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem('accessToken');
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const text = await res.text();
|
||||
if (!text.trim()) {
|
||||
throw new Error(`接口空响应(HTTP ${res.status} ${res.statusText || ''})`);
|
||||
}
|
||||
let json: { code: number; message?: string; data?: T };
|
||||
try {
|
||||
json = JSON.parse(text) as { code: number; message?: string; data?: T };
|
||||
} catch {
|
||||
throw new Error(`接口返回非 JSON(HTTP ${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (json.code === 401) {
|
||||
clearAuth();
|
||||
window.location.href = '/login';
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary?: {
|
||||
count: number;
|
||||
redeemAmount?: number;
|
||||
payoutAmount?: number;
|
||||
orderAmount?: number;
|
||||
orderCommission?: number;
|
||||
redeemCommission?: number;
|
||||
wineryAmount?: number;
|
||||
totalAmount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type DashboardStats = {
|
||||
usersTotal: number;
|
||||
guestUsers: number;
|
||||
verifiedUsers: number;
|
||||
mergedUsers: number;
|
||||
ordersToday: number;
|
||||
storesTotal: number;
|
||||
partnersTotal: number;
|
||||
redeemToday: number;
|
||||
deliveriesTotal: number;
|
||||
pendingPayouts?: number;
|
||||
/** 合伙人已确认、待总部审核打款 */
|
||||
pendingBills?: number;
|
||||
pendingPartnerDraftBills?: number;
|
||||
openTickets?: number;
|
||||
pendingStoreWithdrawals?: number;
|
||||
overdueStoreWithdrawals?: number;
|
||||
ordersByStatus: Array<{ status: string; count: number }>;
|
||||
};
|
||||
|
||||
export type DashboardAnalytics = {
|
||||
summary: {
|
||||
users: number;
|
||||
orders: number;
|
||||
payingUsers: number;
|
||||
partners: number;
|
||||
stores: number;
|
||||
redeems: number;
|
||||
redeemAmount: number;
|
||||
};
|
||||
byDate: Array<{
|
||||
date: string;
|
||||
users: number;
|
||||
orders: number;
|
||||
partners: number;
|
||||
stores: number;
|
||||
redeems: number;
|
||||
redeemAmount: number;
|
||||
}>;
|
||||
byCity: Array<{
|
||||
cityId: string;
|
||||
cityName: string;
|
||||
users: number;
|
||||
orders: number;
|
||||
partners: number;
|
||||
stores: number;
|
||||
redeems: number;
|
||||
redeemAmount: number;
|
||||
}>;
|
||||
byPromo: Array<{
|
||||
promoCodeId: string | null;
|
||||
code: string;
|
||||
name: string;
|
||||
users: number;
|
||||
orders: number;
|
||||
}>;
|
||||
byPartner: Array<{
|
||||
partnerAccountId: string;
|
||||
companyName: string;
|
||||
stores: number;
|
||||
redeems: number;
|
||||
redeemAmount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SystemVersion = {
|
||||
id: string;
|
||||
gitTag: string | null;
|
||||
commitId: string;
|
||||
commitMessage: string;
|
||||
branch: string | null;
|
||||
deployedBy: string | null;
|
||||
deployedAt: string;
|
||||
};
|
||||
|
||||
export type DeployTriggerResult = {
|
||||
accepted: boolean;
|
||||
started?: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AdminUserRow = {
|
||||
id: string;
|
||||
userNo: string;
|
||||
deviceKey: string | null;
|
||||
phone: string | null;
|
||||
phoneVerifiedAt: string | null;
|
||||
mergedIntoUserId: string | null;
|
||||
wxOpenId: string | null;
|
||||
wechatVerified: boolean;
|
||||
nickname: string | null;
|
||||
status: number;
|
||||
sourceType: string;
|
||||
sourceRefId: string | null;
|
||||
sourceLabel: string | null;
|
||||
createdAt: string;
|
||||
orderCount: number;
|
||||
};
|
||||
|
||||
export type AdminOrderItem = {
|
||||
productName: string;
|
||||
productSpec: string;
|
||||
productImage: string;
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type AdminOrderRow = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
deliveryType: string;
|
||||
payAmount: number;
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
createdAt: string;
|
||||
cityId?: string;
|
||||
city?: { id: string; name: string; code: string };
|
||||
fulfillmentWarehouseId?: string | null;
|
||||
fulfillmentWarehouse?: { id: string; name: string } | null;
|
||||
fulfillmentHold?: boolean;
|
||||
fulfillmentHoldReason?: string | null;
|
||||
orderType?: string;
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
trackingNo: string | null;
|
||||
providerOrderNo: string | null;
|
||||
logisticsCompany?: string | null;
|
||||
manualQueryUrl?: string | null;
|
||||
};
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
import { codeToText, regionData } from 'element-china-area-data';
|
||||
|
||||
export { regionData as CHINA_REGION_OPTIONS };
|
||||
|
||||
export type ProvinceCityOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
/** 省 / 市二级(不含区县) */
|
||||
export const PROVINCE_CITY_OPTIONS: ProvinceCityOption[] = regionData.map((province) => ({
|
||||
value: province.value,
|
||||
label: province.label,
|
||||
children: province.children?.map((city) => ({
|
||||
value: city.value,
|
||||
label: city.label,
|
||||
})),
|
||||
}));
|
||||
|
||||
export type OpenCityRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerBindings?: Array<{ partnerAccountId: string }>;
|
||||
boundPartnerIds?: string[];
|
||||
};
|
||||
|
||||
function cityBoundPartnerIds(city: OpenCityRef): string[] {
|
||||
if (city.boundPartnerIds?.length) return city.boundPartnerIds.map(String);
|
||||
return (city.partnerBindings ?? []).map((b) => String(b.partnerAccountId));
|
||||
}
|
||||
|
||||
export type ParsedChinaRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
districtCode: string;
|
||||
};
|
||||
|
||||
export type ParsedProvinceCity = {
|
||||
province: string;
|
||||
city: string;
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
};
|
||||
|
||||
/** element-china-area-data 市级码(如 4101)→ 国标 6 位 adcode(410100) */
|
||||
export function normalizeCityAdcode(code: string): string {
|
||||
const raw = code.trim();
|
||||
if (/^\d{6}$/.test(raw)) return raw;
|
||||
if (/^\d{4}$/.test(raw)) return `${raw}00`;
|
||||
if (/^\d{2}$/.test(raw)) return `${raw}0100`;
|
||||
return raw.padEnd(6, '0').slice(0, 6);
|
||||
}
|
||||
|
||||
export function parseProvinceCityCodes(codes?: string[]): ParsedProvinceCity | null {
|
||||
if (!codes || codes.length < 2) return null;
|
||||
const [provinceCode, cityCode] = codes;
|
||||
const province = codeToText[provinceCode];
|
||||
let city = codeToText[cityCode];
|
||||
if (!province || !city) return null;
|
||||
if (city === '市辖区' || city === '县' || city === '省直辖县级行政区划') {
|
||||
city = province.endsWith('市') ? province : city;
|
||||
}
|
||||
return {
|
||||
province,
|
||||
city,
|
||||
provinceCode,
|
||||
cityCode: normalizeCityAdcode(cityCode),
|
||||
};
|
||||
}
|
||||
|
||||
/** 区县 adcode → 地级市 adcode(如 410105 → 410100) */
|
||||
export function districtCodeToCityCode(districtCode: string): string {
|
||||
if (districtCode.length < 6) return districtCode;
|
||||
return `${districtCode.slice(0, 4)}00`;
|
||||
}
|
||||
|
||||
export type DistrictOption = { value: string; label: string };
|
||||
|
||||
/** 按开城城市 code(4 位或 6 位)取该市下区县列表 */
|
||||
export function getDistrictOptionsByCityCode(cityCode?: string | null): DistrictOption[] {
|
||||
if (!cityCode?.trim()) return [];
|
||||
const normalized = normalizeCityAdcode(cityCode.trim());
|
||||
const prefix4 = normalized.slice(0, 4);
|
||||
|
||||
for (const province of regionData) {
|
||||
for (const city of province.children ?? []) {
|
||||
const cityValue = String(city.value);
|
||||
const cityNorm = normalizeCityAdcode(cityValue);
|
||||
if (cityNorm === normalized || cityValue === prefix4 || cityValue === cityCode.trim()) {
|
||||
return (city.children ?? []).map((d) => ({
|
||||
value: String(d.value),
|
||||
label: String(d.label),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function districtCodeLabel(code: string): string {
|
||||
return codeToText[code] || code;
|
||||
}
|
||||
|
||||
/** 列表展示:区县码 → 中文名,顿号拼接 */
|
||||
export function formatDistrictLabels(codes?: string[] | null): string {
|
||||
if (!codes?.length) return '—';
|
||||
return codes.map((c) => districtCodeLabel(String(c))).join('、');
|
||||
}
|
||||
|
||||
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
|
||||
if (!codes || codes.length < 3) return null;
|
||||
const [provinceCode, cityCode, districtCode] = codes;
|
||||
const province = codeToText[provinceCode];
|
||||
const city = codeToText[cityCode];
|
||||
const district = codeToText[districtCode];
|
||||
if (!province || !city || !district) return null;
|
||||
return { province, city, district, provinceCode, cityCode, districtCode };
|
||||
}
|
||||
|
||||
export function formatRegionLabel(region: ParsedChinaRegion): string {
|
||||
return `${region.province} / ${region.city} / ${region.district}`;
|
||||
}
|
||||
|
||||
export function matchOpenCityId(
|
||||
cities: OpenCityRef[],
|
||||
districtCode: string,
|
||||
partnerAccountId?: string,
|
||||
): string | undefined {
|
||||
const cityCode = districtCodeToCityCode(districtCode);
|
||||
const scoped = partnerAccountId
|
||||
? cities.filter((c) => cityBoundPartnerIds(c).includes(String(partnerAccountId)))
|
||||
: cities;
|
||||
return (
|
||||
scoped.find((c) => c.code === cityCode)?.id
|
||||
?? scoped.find((c) => c.code === districtCode)?.id
|
||||
?? scoped.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRegionBinding(
|
||||
codes: string[],
|
||||
cities: OpenCityRef[],
|
||||
partnerAccountId?: string,
|
||||
) {
|
||||
const region = parseRegionCodes(codes);
|
||||
if (!region) return null;
|
||||
const cityId = matchOpenCityId(cities, region.districtCode, partnerAccountId);
|
||||
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
|
||||
return {
|
||||
region,
|
||||
cityId,
|
||||
matchedCity,
|
||||
cityCode: districtCodeToCityCode(region.districtCode),
|
||||
};
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
|
||||
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
|
||||
|
||||
export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
|
||||
XFX: '小飞侠',
|
||||
LOGISTICS: '物流快递',
|
||||
MANUAL: '手动',
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
/** 订单状态流转操作人(common_event.param3) */
|
||||
export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
|
||||
USER: '用户',
|
||||
USER_ON_SITE: '用户·现场取货',
|
||||
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
||||
WECHAT_TRADE_MANAGE: '微信结算同步',
|
||||
MOCK: 'Mock',
|
||||
MOCK_PAY: 'Mock 支付',
|
||||
SYSTEM: '系统',
|
||||
};
|
||||
|
||||
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||
export const ORDER_STATUS_COLORS: Record<string, string> = {
|
||||
PENDING_PAY: 'red',
|
||||
PENDING_SHIP: 'green',
|
||||
OUT_WAREHOUSE: 'green',
|
||||
SHIPPING: 'processing',
|
||||
PENDING_RECEIVE: 'cyan',
|
||||
COMPLETED: 'green',
|
||||
CANCELLED: 'default',
|
||||
REFUNDING: 'orange',
|
||||
REFUNDED: 'default',
|
||||
};
|
||||
|
||||
export const STORE_STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: '营业中',
|
||||
PAUSED: '临时闭店',
|
||||
CLOSED: '永久关闭',
|
||||
};
|
||||
|
||||
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待审核',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export const ACCOUNT_STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '正常',
|
||||
DISABLED: '停用',
|
||||
};
|
||||
|
||||
export const COUPON_STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '可用',
|
||||
USED_UP: '已用完',
|
||||
VOID: '已作废',
|
||||
};
|
||||
|
||||
export const CITY_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待开城',
|
||||
ACTIVE: '已开城',
|
||||
PAUSED: '已暂停',
|
||||
};
|
||||
|
||||
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_REVIEW: '待审核',
|
||||
AWAITING_CONFIRM: '待合伙人确认',
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export const MEDIA_TYPE_LABELS: Record<string, string> = {
|
||||
IMAGE: '图片',
|
||||
VIDEO: '视频',
|
||||
FILE: '文件',
|
||||
};
|
||||
|
||||
export const RESOURCE_OWNER_TYPE_LABELS: Record<string, string> = {
|
||||
PRODUCT: '商品',
|
||||
STORE: '门店',
|
||||
PARTNER: '合伙人',
|
||||
USER: '用户',
|
||||
ORDER: '订单',
|
||||
HQ: '总部',
|
||||
};
|
||||
|
||||
export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
|
||||
COVER: '封面',
|
||||
ENV: '环境图',
|
||||
CONTRACT: '合同',
|
||||
CAROUSEL: '轮播',
|
||||
DETAIL: '详情',
|
||||
AVATAR: '头像',
|
||||
QRCODE: '二维码',
|
||||
SIGN_PHOTO: '签收照',
|
||||
VIDEO: '视频',
|
||||
};
|
||||
|
||||
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '有效',
|
||||
DELETED: '已删除',
|
||||
};
|
||||
|
||||
export const PRODUCT_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
ON_SALE: '在售',
|
||||
OFF_SALE: '下架',
|
||||
};
|
||||
|
||||
export const AROMA_TYPE_LABELS: Record<string, string> = {
|
||||
QINGXIANG: '清香型',
|
||||
JIANGXIANG: '酱香型',
|
||||
NONGXIANG: '浓香型',
|
||||
};
|
||||
|
||||
export const DETAIL_TEMPLATE_STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '启用',
|
||||
DISABLED: '停用',
|
||||
};
|
||||
|
||||
export const LEDGER_TYPE_LABELS: Record<string, string> = {
|
||||
GRANT: '发放',
|
||||
REDEEM: '核销',
|
||||
REFUND_VOID: '退款作废',
|
||||
ADJUST: '调整',
|
||||
};
|
||||
|
||||
export function fmtTime(v?: string | null) {
|
||||
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
||||
}
|
||||
|
||||
/** 手机号脱敏展示:138****5678;空值返回 — */
|
||||
export function maskPhone(phone?: string | null): string {
|
||||
const raw = String(phone ?? '').trim();
|
||||
if (!raw) return '—';
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
if (digits.length >= 11) {
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||
}
|
||||
if (digits.length >= 7) {
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
|
||||
}
|
||||
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
|
||||
return '****';
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
const ELLIPSIS_TARGETS =
|
||||
'.ant-table-cell, .ant-descriptions-item-content, .ant-typography';
|
||||
|
||||
const ELLIPSIS_SKIP =
|
||||
'.ant-btn, .ant-input, .ant-select, .ant-picker, .ant-checkbox, .ant-radio, pre, textarea, input, select, button, a.ant-btn';
|
||||
|
||||
function isTruncated(el: HTMLElement): boolean {
|
||||
return el.scrollWidth > el.clientWidth + 1;
|
||||
}
|
||||
|
||||
function resolveEllipsisElement(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof HTMLElement)) return null;
|
||||
const cell = target.closest<HTMLElement>(ELLIPSIS_TARGETS);
|
||||
if (!cell) return null;
|
||||
if (cell.closest('.admin-cell-line')) return null;
|
||||
if (cell.closest(ELLIPSIS_SKIP)) return null;
|
||||
if (cell.querySelector(ELLIPSIS_SKIP)) return null;
|
||||
return cell;
|
||||
}
|
||||
|
||||
export function bindAdminEllipsisTitle(root: HTMLElement | Document = document) {
|
||||
const onMouseOver = (event: MouseEvent) => {
|
||||
const el = resolveEllipsisElement(event.target);
|
||||
if (!el) return;
|
||||
if (isTruncated(el)) {
|
||||
const text = el.innerText.replace(/\s+/g, ' ').trim();
|
||||
if (text && el.getAttribute('title') !== text) {
|
||||
el.setAttribute('title', text);
|
||||
}
|
||||
} else {
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
};
|
||||
|
||||
root.addEventListener('mouseover', onMouseOver, true);
|
||||
return () => root.removeEventListener('mouseover', onMouseOver, true);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/** Download CSV (UTF-8 BOM) so Excel opens Chinese columns correctly. */
|
||||
export function downloadExcelCsv(csv: string, filename: string) {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'CITY_CREATE', label: '新增开城城市' },
|
||||
{ value: 'CITY_UPDATE', label: '编辑开城城市' },
|
||||
{ value: 'CITY_DELETE', label: '删除开城城市' },
|
||||
{ value: 'CITY_PARTNER_BIND', label: '绑定城市合伙人' },
|
||||
{ value: 'CITY_PARTNER_UPDATE', label: '编辑城市合伙人绑定' },
|
||||
{ value: 'CITY_PARTNER_UNBIND', label: '解绑城市合伙人' },
|
||||
{ value: 'WAREHOUSE_CREATE', label: '新增城市仓库' },
|
||||
{ value: 'WAREHOUSE_UPDATE', label: '编辑城市仓库' },
|
||||
{ value: 'WAREHOUSE_DELETE', label: '删除城市仓库' },
|
||||
{ value: 'PARTNER_CREATE', label: '新增城市合伙人' },
|
||||
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
|
||||
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
|
||||
{ value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' },
|
||||
{ value: 'PARTNER_ACCOUNT_DELETE', label: '删除合伙人子账号' },
|
||||
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
||||
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
|
||||
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
|
||||
{ value: 'USER_DELETE', label: '删除用户' },
|
||||
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
|
||||
{ value: 'ORDER_SHIP', label: '订单发货' },
|
||||
{ value: 'ORDER_STATUS_DEBUG', label: '订单状态调试' },
|
||||
{ value: 'ORDER_BATCH_DELETE', label: '批量删除订单' },
|
||||
{ value: 'STORE_CREATE', label: '新增门店' },
|
||||
{ value: 'STORE_UPDATE', label: '编辑门店' },
|
||||
{ value: 'STORE_STATUS', label: '变更门店状态' },
|
||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
|
||||
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
|
||||
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
|
||||
{ value: 'PRODUCT_CREATE', label: '新增商品' },
|
||||
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
|
||||
{ value: 'PRODUCT_DELETE', label: '删除商品' },
|
||||
{ value: 'BENEFIT_COUPON_VOID', label: '作废权益券' },
|
||||
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
|
||||
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
||||
{ value: 'TICKET_CREATE', label: '创建工单' },
|
||||
{ value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' },
|
||||
{ value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' },
|
||||
{ value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' },
|
||||
{ value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' },
|
||||
{ value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' },
|
||||
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
|
||||
{ value: 'INVOICE_ISSUE', label: '开具发票' },
|
||||
{ value: 'INVOICE_REJECT', label: '驳回发票' },
|
||||
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
||||
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
||||
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
|
||||
{ value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' },
|
||||
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' },
|
||||
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
|
||||
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
|
||||
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
|
||||
] as const;
|
||||
|
||||
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
|
||||
HQ_OPERATION_ACTION_OPTIONS.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
export function resolveHqOperationLabel(action: string | null | undefined): string {
|
||||
if (!action) return '—';
|
||||
return HQ_OPERATION_ACTION_LABELS[action] || action;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -1,64 +0,0 @@
|
||||
/** 商品详情页文案模板(Admin 一键套用) */
|
||||
export const TEMPLATE_MAX_DETAIL_IMAGES = 30;
|
||||
|
||||
export type ProductDetailTemplateContent = {
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||
/** 模板内置详情长图 URL 列表 */
|
||||
detailImageUrls?: string[];
|
||||
/** 建议详情长图张数(与 detailImageUrls 数量同步,供展示) */
|
||||
suggestedDetailImageCount?: number;
|
||||
};
|
||||
|
||||
export type ProductDetailTemplate = {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
description: string;
|
||||
aromaType?: string;
|
||||
content: ProductDetailTemplateContent;
|
||||
};
|
||||
|
||||
/** API 返回的详情模板 DTO(与 shared-types ProductDetailTemplateDto 对齐) */
|
||||
export type ProductDetailTemplateDto = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
aromaType?: string | null;
|
||||
storyTitle?: string | null;
|
||||
storyText?: string | null;
|
||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||
detailImageUrls?: string[];
|
||||
suggestedDetailImageCount: number;
|
||||
sortOrder: number;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function mapDtoToProductDetailTemplate(dto: ProductDetailTemplateDto): ProductDetailTemplate {
|
||||
const detailImageUrls = (dto.detailImageUrls ?? []).filter(Boolean);
|
||||
return {
|
||||
id: dto.code,
|
||||
code: dto.code,
|
||||
label: dto.name,
|
||||
description: dto.description ?? '',
|
||||
aromaType: dto.aromaType ?? undefined,
|
||||
content: {
|
||||
storyTitle: dto.storyTitle ?? undefined,
|
||||
storyText: dto.storyText ?? undefined,
|
||||
features: dto.features ?? [],
|
||||
detailImageUrls,
|
||||
suggestedDetailImageCount: detailImageUrls.length || dto.suggestedDetailImageCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductDetailTemplate(
|
||||
templates: ProductDetailTemplate[],
|
||||
code: string,
|
||||
) {
|
||||
return templates.find((t) => t.code === code || t.id === code);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
STORE_LOG_CATEGORY_OPTIONS,
|
||||
STORE_LOG_CATEGORY_LABELS,
|
||||
resolveStoreLogCategory,
|
||||
type StoreLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -1,107 +0,0 @@
|
||||
export type StoreCreateForm = {
|
||||
partnerAccountId: string;
|
||||
cityId: string;
|
||||
regionCodes?: string[];
|
||||
province?: string;
|
||||
city?: string;
|
||||
district: string;
|
||||
districtCode?: string;
|
||||
categoryParentId?: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
intro?: string;
|
||||
benefitUsageRule?: string;
|
||||
openTime?: string;
|
||||
closeTime?: string;
|
||||
openTime2?: string;
|
||||
closeTime2?: string;
|
||||
avgPrice?: number | null;
|
||||
coverUrl?: string;
|
||||
envPhotoUrls?: string[];
|
||||
contractUrl?: string;
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
settlementRate?: number;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
};
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
function timeToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
export function validateStoreCreateStep1(
|
||||
form: Pick<
|
||||
StoreCreateForm,
|
||||
| 'partnerAccountId'
|
||||
| 'cityId'
|
||||
| 'regionCodes'
|
||||
| 'categoryId'
|
||||
| 'name'
|
||||
| 'phone'
|
||||
| 'address'
|
||||
| 'intro'
|
||||
| 'benefitUsageRule'
|
||||
| 'openTime'
|
||||
| 'closeTime'
|
||||
| 'openTime2'
|
||||
| 'closeTime2'
|
||||
| 'avgPrice'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
||||
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
|
||||
if (!form.name?.trim()) return '请填写门店名称';
|
||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||
if (!form.address?.trim()) return '请填写详细地址';
|
||||
|
||||
const openTime = String(form.openTime || '').trim();
|
||||
const closeTime = String(form.closeTime || '').trim();
|
||||
if (!openTime || !closeTime) return '请填写营业时间';
|
||||
if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM';
|
||||
if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间';
|
||||
|
||||
const openTime2 = String(form.openTime2 || '').trim();
|
||||
const closeTime2 = String(form.closeTime2 || '').trim();
|
||||
if (openTime2 || closeTime2) {
|
||||
if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间';
|
||||
if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM';
|
||||
if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间';
|
||||
if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间';
|
||||
}
|
||||
|
||||
if (form.avgPrice != null && String(form.avgPrice).trim() !== '') {
|
||||
const n = Number(form.avgPrice);
|
||||
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
|
||||
}
|
||||
|
||||
if (form.intro?.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
|
||||
}
|
||||
if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) {
|
||||
return '好客权益券使用规则最多 1000 字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateStoreCreateStep3(form: Pick<StoreCreateForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>): string | null {
|
||||
if (!form.bankAccountName?.trim()) return '请填写户主姓名';
|
||||
if (!form.bankAccountNo?.trim()) return '请填写银行卡号';
|
||||
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
||||
if (!form.bankBranch?.trim()) return '请填写开户支行';
|
||||
return null;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export type TencentPickedLocation = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
name?: string;
|
||||
cityname?: string;
|
||||
};
|
||||
|
||||
export type LbsPlaceItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city?: string;
|
||||
};
|
||||
|
||||
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
|
||||
return {
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
address: item.address || undefined,
|
||||
name: item.title || undefined,
|
||||
cityname: item.city || undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||
export async function uploadFileToOss(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-Client-App': CLIENT_APP,
|
||||
};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 401) {
|
||||
clearAuth();
|
||||
window.location.href = '/login';
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (json.code !== 0) throw new Error(json.message || '上传失败');
|
||||
|
||||
const data = json.data as UploadFileResult;
|
||||
return {
|
||||
url: data.url,
|
||||
ossKey: data.ossKey,
|
||||
bucket: data.bucket,
|
||||
mock: data.mock,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { request, type Paginated } from './api';
|
||||
|
||||
export function useAdminList<T>(path: string, buildQuery: () => URLSearchParams, deps: unknown[]) {
|
||||
const [data, setData] = useState<Paginated<T> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = buildQuery();
|
||||
qs.set('page', String(page));
|
||||
qs.set('pageSize', String(pageSize));
|
||||
const res = await request<Paginated<T>>(`${path}?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [path, page, pageSize, ...deps]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { data, loading, page, pageSize, setPage, setPageSize, reload: load };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export {
|
||||
USER_LOG_CATEGORY_OPTIONS,
|
||||
resolveUserLogCategory,
|
||||
eventNamesForUserLogCategory,
|
||||
type UserLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
import { USER_LOG_CATEGORY_OPTIONS } from '@dukang/shared-types';
|
||||
|
||||
export const USER_LOG_CATEGORY_LABELS: Record<string, string> = Object.fromEntries(
|
||||
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import App from './App';
|
||||
import './admin.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,465 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
couponNo: string;
|
||||
totalAmount: number;
|
||||
balance: number;
|
||||
usedAmount: number;
|
||||
status: string;
|
||||
sourceProduct: string;
|
||||
createdAt: string;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
order?: { orderNo: string } | null;
|
||||
};
|
||||
|
||||
type CouponRedeemRecord = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
couponAmount?: number;
|
||||
role?: 'PRIMARY' | 'SECONDARY';
|
||||
createdAt: string;
|
||||
store?: { id: string; name: string; cityName?: string | null } | null;
|
||||
};
|
||||
|
||||
type CouponRedeemSummary = {
|
||||
couponNo: string;
|
||||
totalAmount: number;
|
||||
usedAmount: number;
|
||||
balance: number;
|
||||
status: string;
|
||||
redeemCount: number;
|
||||
redeemRecordSum: number;
|
||||
};
|
||||
|
||||
type CouponDetail = Row & {
|
||||
redeemSummary?: CouponRedeemSummary | null;
|
||||
redeemRecords?: CouponRedeemRecord[];
|
||||
user?: { userNo?: string; phone?: string | null };
|
||||
order?: { orderNo?: string } | null;
|
||||
};
|
||||
|
||||
export default function BenefitCouponsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const [granting, setGranting] = useState(false);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/benefit/coupons',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.couponNo) qs.set('couponNo', filters.couponNo);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.userId) qs.set('userId', filters.userId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<CouponDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||
|
||||
async function openRedeemDetail(redeemId: string) {
|
||||
setRedeemDetailLoading(true);
|
||||
setRedeemDrawerOpen(true);
|
||||
try {
|
||||
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
|
||||
setRedeemDetail(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载核销详情失败');
|
||||
setRedeemDrawerOpen(false);
|
||||
} finally {
|
||||
setRedeemDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
||||
{
|
||||
title: '订单',
|
||||
dataIndex: ['order', 'orderNo'],
|
||||
width: 180,
|
||||
ellipsis: false,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
async function handleGrant(values: AdminBenefitGrantRequest) {
|
||||
setGranting(true);
|
||||
try {
|
||||
await request('/admin/benefit/coupons/grant', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
phone: values.phone.trim(),
|
||||
amount: values.amount,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('权益已发放');
|
||||
setGrantOpen(false);
|
||||
grantForm.resetFields();
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发放失败');
|
||||
} finally {
|
||||
setGranting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
好客权益券
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||
手动发放
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="couponNo" label="券号">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(COUPON_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</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: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="手动发放好客权益"
|
||||
open={grantOpen}
|
||||
onCancel={() => setGrantOpen(false)}
|
||||
onOk={() => grantForm.submit()}
|
||||
confirmLoading={granting}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={grantForm} layout="vertical" onFinish={(v) => void handleGrant(v)}>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="用户手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="已注册 C 端用户的手机号" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="权益金额(元)"
|
||||
rules={[{ required: true, message: '请输入权益金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
max={999999.99}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="发放金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={200} placeholder="可选,默认「总部手动发放」" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title="权益券详情"
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail &&
|
||||
detail.status !== 'VOID' && (
|
||||
<Popconfirm
|
||||
title="确认作废此券?"
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/benefit/coupons/${detail.id}/void`, { method: 'POST' });
|
||||
message.success('已作废');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button danger>作废</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
||||
权益核销
|
||||
</Typography.Title>
|
||||
{detail.redeemSummary ? (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="权益总额">
|
||||
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已核销">
|
||||
<Typography.Text type="danger" strong>
|
||||
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
({detail.redeemSummary.redeemCount} 笔核销单)
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="剩余余额">
|
||||
¥{Number(detail.redeemSummary.balance).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销单合计额">
|
||||
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
(本券分摊合计)
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 70,
|
||||
render: (v: string | undefined) =>
|
||||
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||
},
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
ellipsis: true,
|
||||
render: (v: string | undefined, row: CouponRedeemRecord) =>
|
||||
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||
},
|
||||
{
|
||||
title: '本券分摊',
|
||||
dataIndex: 'couponAmount',
|
||||
width: 95,
|
||||
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
||||
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '核销总额',
|
||||
dataIndex: 'amount',
|
||||
width: 90,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '结算额',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 90,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
render: (v: string) => fmtTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 70,
|
||||
render: (_: unknown, row: CouponRedeemRecord) => (
|
||||
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无核销摘要</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="核销单详情"
|
||||
width={520}
|
||||
open={redeemDrawerOpen}
|
||||
onClose={() => {
|
||||
setRedeemDrawerOpen(false);
|
||||
setRedeemDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
{redeemDetailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : redeemDetail ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">
|
||||
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">
|
||||
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
|
||||
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
|
||||
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
|
||||
? `(${(redeemDetail.store as { cityName?: string }).cityName})`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址">
|
||||
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">
|
||||
{String(
|
||||
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
|
||||
?.partnerAccount?.companyName ?? '—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="权益券号">
|
||||
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">
|
||||
{String(
|
||||
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
|
||||
'—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{Array.isArray(redeemDetail.allocations) &&
|
||||
(redeemDetail.allocations as unknown[]).length > 0 ? (
|
||||
<Descriptions.Item label="券分摊">
|
||||
{(
|
||||
redeemDetail.allocations as Array<{
|
||||
couponNo?: string;
|
||||
orderNo?: string | null;
|
||||
amount?: number;
|
||||
sortOrder?: number;
|
||||
}>
|
||||
)
|
||||
.map((a, idx) => {
|
||||
const role = idx === 0 ? '主' : '次';
|
||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||
}`;
|
||||
})
|
||||
.join(';')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.payout ? (
|
||||
<Descriptions.Item label="门店结算单">
|
||||
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||
{' / '}
|
||||
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.rating ? (
|
||||
<Descriptions.Item label="评价">
|
||||
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
|
||||
user?: { userNo: string }; coupon?: { couponNo: string };
|
||||
};
|
||||
|
||||
export default function BenefitLedgersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/benefit/ledgers',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.userId) qs.set('userId', filters.userId);
|
||||
if (filters.couponId) qs.set('couponId', filters.couponId);
|
||||
if (filters.type) qs.set('type', filters.type);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
|
||||
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
|
||||
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>权益流水</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(LEDGER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</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: 1000 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
|
||||
import { request, type HqProfile, type Paginated } from '../lib/api';
|
||||
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityPartnersPanel from '../components/CityPartnersPanel';
|
||||
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
||||
|
||||
type WarehouseRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
province: string;
|
||||
status: string;
|
||||
storeCount: number;
|
||||
orderCount: number;
|
||||
partnerBindingCount?: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
|
||||
|
||||
type CityDeletePreview = {
|
||||
city: { id: string; code: string; name: string; province: string; status: string };
|
||||
canDelete: boolean;
|
||||
blockers: string[];
|
||||
warnings: string[];
|
||||
summary: {
|
||||
primaryPartnerCount: number;
|
||||
staffCount: number;
|
||||
storeCount: number;
|
||||
warehouseCount: number;
|
||||
orderCount: number;
|
||||
redeemCount: number;
|
||||
partnerBillCount: number;
|
||||
};
|
||||
partners: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
companyName?: string | null;
|
||||
status: string;
|
||||
staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
|
||||
}>;
|
||||
orphanStaff: Array<{ id: string; phone: string; name: string }>;
|
||||
stores: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
address: string;
|
||||
partnerAccount?: { companyName?: string | null; phone?: string } | null;
|
||||
}>;
|
||||
warehouses: Array<{ id: string; name: string; status: string; address: string }>;
|
||||
};
|
||||
|
||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export default function CitiesPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [warehouseForm] = Form.useForm();
|
||||
const [warehouseEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/cities',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.code) qs.set('code', filters.code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseRow[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [warehouseOpen, setWarehouseOpen] = useState(false);
|
||||
const [warehouseEditOpen, setWarehouseEditOpen] = useState(false);
|
||||
const [warehouseEditId, setWarehouseEditId] = useState<string | null>(null);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [createRegionPreview, setCreateRegionPreview] = useState<ParsedProvinceCity | null>(null);
|
||||
const createRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
|
||||
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
|
||||
const [confirmName, setConfirmName] = useState('');
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete');
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadPartners = useCallback(async (cityId: string) => {
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
|
||||
setPartners(res.items);
|
||||
}, []);
|
||||
|
||||
const loadWarehouses = useCallback(async (cityId: string) => {
|
||||
const wh = await request<WarehouseRow[]>(`/admin/cities/${cityId}/warehouses`);
|
||||
setWarehouses(wh);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createRegionCodes?.length) {
|
||||
setCreateRegionPreview(null);
|
||||
return;
|
||||
}
|
||||
const parsed = parseProvinceCityCodes(createRegionCodes as string[]);
|
||||
createForm.setFieldsValue({
|
||||
code: parsed?.cityCode,
|
||||
name: parsed?.city,
|
||||
province: parsed?.province,
|
||||
});
|
||||
setCreateRegionPreview(parsed);
|
||||
}, [createRegionCodes, createForm]);
|
||||
|
||||
function openCreateModal() {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ status: 'PENDING' });
|
||||
setCreateRegionPreview(null);
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
const openDetail = async (row: Row) => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
maxPartnerCommissionPercent:
|
||||
d.maxPartnerCommissionRate != null
|
||||
? Number(d.maxPartnerCommissionRate) * 100
|
||||
: 5,
|
||||
});
|
||||
await loadPartners(row.id);
|
||||
await loadWarehouses(row.id);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openDelete = async (row: Row) => {
|
||||
setDeleteTarget(row);
|
||||
setDeletePreview(null);
|
||||
setConfirmName('');
|
||||
setDeleteOpen(true);
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const preview = await request<CityDeletePreview>(`/admin/cities/${row.id}/delete-preview`);
|
||||
setDeletePreview(preview);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载删除预览失败');
|
||||
setDeleteOpen(false);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget || !deletePreview) return;
|
||||
if (!deletePreview.canDelete) {
|
||||
message.error(deletePreview.blockers.join(';') || '当前城市不可删除');
|
||||
return;
|
||||
}
|
||||
if (confirmName.trim() !== deletePreview.city.name) {
|
||||
message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
|
||||
return;
|
||||
}
|
||||
setDeleteSubmitting(true);
|
||||
try {
|
||||
await request(`/admin/cities/${deleteTarget.id}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ confirmName: confirmName.trim() }),
|
||||
});
|
||||
message.success(`已删除城市「${deletePreview.city.name}」`);
|
||||
setDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
setDeletePreview(null);
|
||||
setConfirmName('');
|
||||
if (detail?.id === deleteTarget.id) setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setDeleteSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: 'storeCount',
|
||||
width: 70,
|
||||
render: (n: number, row) => (
|
||||
<Link to={`/stores?cityId=${row.id}`} title={`查看「${row.name}」门店`}>
|
||||
{n ?? 0}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
管理
|
||||
</Button>
|
||||
{canDeleteCities ? (
|
||||
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const warehouseColumns: ColumnsType<WarehouseRow> = [
|
||||
{ title: '仓库名', dataIndex: 'name' },
|
||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
title: '管仓',
|
||||
dataIndex: 'managerType',
|
||||
width: 100,
|
||||
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
|
||||
},
|
||||
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setWarehouseEditId(row.id);
|
||||
setEditWarehouseManagerType(row.managerType);
|
||||
warehouseEditForm.setFieldsValue(row);
|
||||
setWarehouseEditOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认删除仓库?',
|
||||
onOk: async () => {
|
||||
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>城市</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateModal}>新建城市</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="code" label="编码"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</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 ?? []}
|
||||
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)}>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人数">{String(detail.partnerBindingCount ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item
|
||||
name="maxPartnerCommissionPercent"
|
||||
label="合伙人佣金合计上限 %"
|
||||
extra="订单佣金 + 核销佣金不得超过此比例;不填默认 5%"
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} placeholder="5" />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
const { maxPartnerCommissionPercent, ...rest } = v;
|
||||
await request(`/admin/cities/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...rest,
|
||||
maxPartnerCommissionRate:
|
||||
maxPartnerCommissionPercent != null && maxPartnerCommissionPercent !== ''
|
||||
? Number(maxPartnerCommissionPercent) / 100
|
||||
: 0.05,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
const refreshed = await request<Record<string, unknown>>(`/admin/cities/${detail.id}`);
|
||||
setDetail(refreshed);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
{canDeleteCities ? (
|
||||
<Button
|
||||
danger
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() =>
|
||||
void openDelete({
|
||||
id: String(detail.id),
|
||||
code: String(detail.code),
|
||||
name: String(detail.name),
|
||||
province: String(detail.province),
|
||||
status: String(detail.status),
|
||||
storeCount: Number(detail.storeCount ?? 0),
|
||||
orderCount: Number(detail.orderCount ?? 0),
|
||||
createdAt: String(detail.createdAt ?? ''),
|
||||
})
|
||||
}
|
||||
>
|
||||
删除城市
|
||||
</Button>
|
||||
) : null}
|
||||
</Form>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'partners',
|
||||
label: '合伙人',
|
||||
children: detail?.id ? (
|
||||
<CityPartnersPanel
|
||||
cityId={String(detail.id)}
|
||||
cityCode={detail.code != null ? String(detail.code) : undefined}
|
||||
maxPartnerCommissionRate={
|
||||
detail.maxPartnerCommissionRate != null
|
||||
? Number(detail.maxPartnerCommissionRate)
|
||||
: 0.05
|
||||
}
|
||||
onChanged={() => {
|
||||
void reload();
|
||||
void loadPartners(String(detail.id));
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'warehouses',
|
||||
label: '仓库管理',
|
||||
children: (
|
||||
<>
|
||||
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => {
|
||||
warehouseForm.resetFields();
|
||||
warehouseForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: 'ACTIVE' });
|
||||
setWarehouseManagerType(WarehouseManagerType.HQ);
|
||||
setWarehouseOpen(true);
|
||||
}}>新增仓库</Button>
|
||||
<Table rowKey="id" size="small" columns={warehouseColumns} dataSource={warehouses} pagination={false} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
if (!v.code || !v.name || !v.province) {
|
||||
message.error('请选择省 / 市');
|
||||
return;
|
||||
}
|
||||
await request('/admin/cities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: v.code,
|
||||
name: v.name,
|
||||
province: v.province,
|
||||
status: v.status,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
setCreateRegionPreview(null);
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="regionCodes"
|
||||
label="所在地区"
|
||||
rules={[{ required: true, message: '请选择省 / 市' }]}
|
||||
>
|
||||
<ChinaProvinceCityCascader />
|
||||
</Form.Item>
|
||||
{createRegionPreview ? (
|
||||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="城市编码">{createRegionPreview.cityCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="省份">{createRegionPreview.province}</Descriptions.Item>
|
||||
<Descriptions.Item label="城市名">{createRegionPreview.city}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
选择省 / 市后,城市编码将自动填入(不可编辑)
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Form.Item name="code" hidden rules={[{ required: true, message: '请选择省 / 市' }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" hidden rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" hidden rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="PENDING">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="新增仓库" open={warehouseOpen} onCancel={() => setWarehouseOpen(false)} onOk={async () => {
|
||||
const v = await warehouseForm.validateFields();
|
||||
await request(`/admin/cities/${detail?.id}/warehouses`, { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setWarehouseOpen(false);
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
}}>
|
||||
<Form form={warehouseForm} layout="vertical">
|
||||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||||
<Select options={MANAGER_OPTIONS} onChange={(v) => setWarehouseManagerType(v)} />
|
||||
</Form.Item>
|
||||
{warehouseManagerType === WarehouseManagerType.PARTNER && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑仓库" open={warehouseEditOpen} onCancel={() => setWarehouseEditOpen(false)} onOk={async () => {
|
||||
const v = await warehouseEditForm.validateFields();
|
||||
await request(`/admin/city-warehouses/${warehouseEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setWarehouseEditOpen(false);
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
}}>
|
||||
<Form form={warehouseEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||||
<Select options={MANAGER_OPTIONS} onChange={(v) => setEditWarehouseManagerType(v)} />
|
||||
</Form.Item>
|
||||
{editWarehouseManagerType === WarehouseManagerType.PARTNER && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={deleteTarget ? `删除城市「${deleteTarget.name}」` : '删除城市'}
|
||||
open={deleteOpen}
|
||||
onCancel={() => {
|
||||
if (deleteSubmitting) return;
|
||||
setDeleteOpen(false);
|
||||
}}
|
||||
okText="确认删除"
|
||||
okButtonProps={{
|
||||
danger: true,
|
||||
disabled:
|
||||
!deletePreview?.canDelete ||
|
||||
!deletePreview ||
|
||||
confirmName.trim() !== (deletePreview?.city.name ?? ''),
|
||||
loading: deleteSubmitting,
|
||||
}}
|
||||
confirmLoading={deleteSubmitting}
|
||||
onOk={() => void confirmDelete()}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
{deleteLoading || !deletePreview ? (
|
||||
<Typography.Text type="secondary">正在加载关联数据…</Typography.Text>
|
||||
) : (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Descriptions size="small" bordered column={2}>
|
||||
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{deletePreview.blockers.length > 0 && (
|
||||
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
|
||||
{deletePreview.blockers.map((b) => (
|
||||
<div key={b}>• {b}</div>
|
||||
))}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
{deletePreview.warnings.length > 0 && (
|
||||
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
|
||||
{deletePreview.warnings.map((w) => (
|
||||
<div key={w}>• {w}</div>
|
||||
))}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Typography.Text strong>合伙人及子账号</Typography.Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
locale={{ emptyText: '无合伙人' }}
|
||||
dataSource={deletePreview.partners.flatMap((p) => [
|
||||
{
|
||||
id: p.id,
|
||||
kind: '主账号',
|
||||
name: p.companyName || p.name,
|
||||
phone: p.phone,
|
||||
parent: '—',
|
||||
},
|
||||
...p.staff.map((s) => ({
|
||||
id: s.id,
|
||||
kind: '子账号',
|
||||
name: s.name,
|
||||
phone: s.phone,
|
||||
parent: p.companyName || p.name,
|
||||
})),
|
||||
]).concat(
|
||||
deletePreview.orphanStaff.map((s) => ({
|
||||
id: s.id,
|
||||
kind: '子账号',
|
||||
name: s.name,
|
||||
phone: s.phone,
|
||||
parent: '(无主账号)',
|
||||
})),
|
||||
)}
|
||||
columns={[
|
||||
{ title: '类型', dataIndex: 'kind', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||
{ title: '归属', dataIndex: 'parent', ellipsis: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong>门店</Typography.Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
locale={{ emptyText: '无门店' }}
|
||||
dataSource={deletePreview.stores}
|
||||
columns={[
|
||||
{ title: '门店名', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deletePreview.canDelete ? (
|
||||
<Form.Item
|
||||
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
value={confirmName}
|
||||
placeholder={deletePreview.city.name}
|
||||
onChange={(e) => setConfirmName(e.target.value)}
|
||||
disabled={deleteSubmitting}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Typography.Text type="secondary">存在阻断项,无法删除。请先处理订单等关联数据。</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,706 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||
|
||||
type SubRow = PartnerSubAccountRow;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
contactPhone?: string | null;
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
bindingStatus?: string;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
maxPartnerCommissionRate?: number | null;
|
||||
storeCount: number;
|
||||
accountCount: number;
|
||||
subAccounts?: SubRow[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
/** 详情接口仍返回 children */
|
||||
children?: SubRow[];
|
||||
};
|
||||
|
||||
function toTableRows(items: Row[]): Row[] {
|
||||
return items.map((row) => {
|
||||
const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] };
|
||||
return { ...rest, subAccounts: subAccounts ?? children ?? [] };
|
||||
});
|
||||
}
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [subEditParentId, setSubEditParentId] = useState<string | null>(null);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
|
||||
const [createMaxRate, setCreateMaxRate] = useState(0.05);
|
||||
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
const editCityCode = detail?.cityId
|
||||
? cities.find((c) => c.id === detail.cityId)?.code
|
||||
: undefined;
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPartnerContext(parentId: string) {
|
||||
if (detail?.id === parentId) {
|
||||
await openPartner(parentId);
|
||||
}
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string, parentId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
await refreshPartnerContext(parentId);
|
||||
}
|
||||
|
||||
function openSubEdit(row: SubRow, parentId: string) {
|
||||
setSubEditId(row.id);
|
||||
setSubEditParentId(parentId);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}
|
||||
|
||||
async function openAddSubAccount(parentId: string) {
|
||||
await openPartner(parentId);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
async function onCreateCityChange(cityId: string) {
|
||||
createForm.setFieldsValue({ districtCodes: undefined });
|
||||
const matched = cities.find((c) => c.id === cityId);
|
||||
setCreateCityCode(matched?.code);
|
||||
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
|
||||
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 90,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 110,
|
||||
render: (_, row) =>
|
||||
`${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '绑定状态',
|
||||
dataIndex: 'bindingStatus',
|
||||
width: 80,
|
||||
render: (v) => (
|
||||
<Tag color={v === CityPartnerStatus.ACTIVE ? 'green' : 'default'}>
|
||||
{CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '管仓仓库',
|
||||
dataIndex: 'managedWarehouseName',
|
||||
width: 110,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: 'storeCount',
|
||||
width: 60,
|
||||
render: (n: number, row) => (
|
||||
<Link to={`/stores?partnerId=${row.id}`} title={`查看「${row.companyName || row.phone}」门店`}>
|
||||
{n ?? 0}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
城市合伙人
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateMaxRate(0.05);
|
||||
setCreateCityCode(undefined);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
<>
|
||||
账号结构仅两级:主账号 → 子账号(子账号不可再添加下级)。管仓仓库请在{' '}
|
||||
<Link to="/city-warehouses">仓库管理</Link> 中分配。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
placeholder="全部城市"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input allowClear placeholder="公司名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
filterForm.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={toTableRows(data?.items ?? [])}
|
||||
scroll={{ x: 1200 }}
|
||||
childrenColumnName="__noTreeChildren__"
|
||||
expandable={{
|
||||
expandedRowRender: (record) => (
|
||||
<PartnerSubAccountList
|
||||
subs={record.subAccounts ?? []}
|
||||
onAdd={() => void openAddSubAccount(record.id)}
|
||||
onEdit={(sub) => openSubEdit(sub, record.id)}
|
||||
onDelete={(subId) => void deleteSubAccount(subId, record.id)}
|
||||
/>
|
||||
),
|
||||
rowExpandable: () => true,
|
||||
columnWidth: 40,
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => void savePartner()}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店数">
|
||||
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
|
||||
{detail.storeCount ?? 0}
|
||||
</Link>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="管仓仓库" span={2}>
|
||||
{detail.managedWarehouseName ?? (
|
||||
<Typography.Text type="secondary">
|
||||
未分配(请前往 <Link to="/city-warehouses">仓库管理</Link> 设置)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名">
|
||||
<Input placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={editCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
<Form.Item name="bankAccountName" label="户名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
||||
onDelete={(subId) => void deleteSubAccount(subId, detail.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="新建城市合伙人"
|
||||
open={createOpen}
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
createMaxRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes:
|
||||
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => void onCreateCityChange(id)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名">
|
||||
<Input placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block' }}>
|
||||
订单 + 核销合计不得超过 {(createMaxRate * 100).toFixed(2)}%(由所选城市配置决定)
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={detail ? `添加子账号 · ${detail.companyName}` : '添加子账号'}
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
await refreshPartnerContext(detail.id);
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑子账号"
|
||||
open={subEditOpen}
|
||||
onCancel={() => setSubEditOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!subEditId) return;
|
||||
const parentId = detail?.id ?? subEditParentId;
|
||||
if (!parentId) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
await refreshPartnerContext(parentId);
|
||||
}}
|
||||
>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'ACTIVE', label: '启用' },
|
||||
{ value: 'DISABLED', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,515 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WAREHOUSE_FULFILLMENT_MODE_LABELS,
|
||||
WAREHOUSE_MANAGER_LABELS,
|
||||
WAREHOUSE_STATUS_LABELS,
|
||||
WarehouseFulfillmentMode,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
type FulfillmentProviderDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
cityId: string;
|
||||
cityName: string;
|
||||
cityCode: string;
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: string;
|
||||
fulfillmentMode?: string;
|
||||
fulfillmentProviderId?: string | null;
|
||||
fulfillmentProviderName?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
|
||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
function FulfillmentFields({
|
||||
mode,
|
||||
providerOptions,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: WarehouseFulfillmentMode;
|
||||
providerOptions: FulfillmentProviderDto[];
|
||||
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||||
}) {
|
||||
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="fulfillmentMode"
|
||||
label="支付后自动发货"
|
||||
extra={
|
||||
autoShip
|
||||
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
|
||||
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
|
||||
}
|
||||
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
|
||||
getValueFromEvent={(checked: boolean) =>
|
||||
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
onChange={(checked) => {
|
||||
onModeChange(
|
||||
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{autoShip && (
|
||||
<Form.Item
|
||||
name="fulfillmentProviderId"
|
||||
label="仓配承运商"
|
||||
rules={[{ required: true, message: '自动发货须选择承运商' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
|
||||
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!autoShip && (
|
||||
<>
|
||||
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
|
||||
<Input placeholder="如 顺丰速运" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="manualQueryUrlTemplate"
|
||||
label="物流查询链接模板"
|
||||
extra="可用 {trackingNo} 占位符"
|
||||
>
|
||||
<Input placeholder="https://example.com/track?no={trackingNo}" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Space>
|
||||
<Form.Item name="lng" label="经度">
|
||||
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
|
||||
</Form.Item>
|
||||
<Form.Item name="lat" label="纬度">
|
||||
<InputNumber step={0.001} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CityWarehousesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/city-warehouses',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.managerType) qs.set('managerType', filters.managerType);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<Row | null>(null);
|
||||
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||||
WarehouseFulfillmentMode.MANUAL,
|
||||
);
|
||||
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||||
WarehouseFulfillmentMode.MANUAL,
|
||||
);
|
||||
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
const loadPartners = useCallback(async (cityId: string) => {
|
||||
const res = await request<Paginated<PartnerOption>>(
|
||||
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`,
|
||||
);
|
||||
setPartners(res.items);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
|
||||
.then(setProviderOptions)
|
||||
.catch(() => {});
|
||||
}, [loadCities]);
|
||||
|
||||
async function openEdit(row: Row) {
|
||||
setEditRow(row);
|
||||
setEditManagerType(row.managerType);
|
||||
await loadPartners(row.cityId);
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
contactName: row.contactName,
|
||||
contactPhone: row.contactPhone,
|
||||
managerType: row.managerType,
|
||||
partnerAccountId: row.partnerAccountId,
|
||||
status: row.status,
|
||||
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
|
||||
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
|
||||
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
|
||||
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
|
||||
lng: row.lng ?? undefined,
|
||||
lat: row.lat ?? undefined,
|
||||
});
|
||||
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function onManagerTypeChange(
|
||||
v: WarehouseManagerType,
|
||||
form: typeof createForm,
|
||||
setType: (t: WarehouseManagerType) => void,
|
||||
) {
|
||||
setType(v);
|
||||
if (v !== WarehouseManagerType.PARTNER) {
|
||||
form.setFieldValue('partnerAccountId', undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '城市',
|
||||
width: 100,
|
||||
render: (_, row) => (
|
||||
<span title={row.cityCode}>
|
||||
{row.cityName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
|
||||
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
title: '管仓',
|
||||
width: 90,
|
||||
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
|
||||
},
|
||||
{
|
||||
title: '合伙人',
|
||||
dataIndex: 'partnerCompanyName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) =>
|
||||
v && row.partnerAccountId ? (
|
||||
<Link to="/city-partners">{v}</Link>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '自动发货',
|
||||
width: 130,
|
||||
render: (_, row) =>
|
||||
row.fulfillmentMode === 'API_AUTO' ? (
|
||||
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
|
||||
) : (
|
||||
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === WarehouseStatus.ACTIVE ? 'green' : 'default'}>
|
||||
{WAREHOUSE_STATUS_LABELS[s as WarehouseStatus] || s}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除该仓库?"
|
||||
description="删除后将解除关联合伙人的管仓绑定"
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateManagerType(WarehouseManagerType.HQ);
|
||||
setCreateCityId(undefined);
|
||||
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||||
createForm.setFieldsValue({
|
||||
managerType: WarehouseManagerType.HQ,
|
||||
status: WarehouseStatus.ACTIVE,
|
||||
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新增仓库
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="name" label="仓库名">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 140 }}
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="managerType" label="管仓">
|
||||
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新增仓库"
|
||||
open={createOpen}
|
||||
width={520}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request(`/admin/cities/${v.cityId}/warehouses`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => {
|
||||
setCreateCityId(id);
|
||||
createForm.setFieldValue('partnerAccountId', undefined);
|
||||
void loadPartners(id);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={MANAGER_OPTIONS}
|
||||
onChange={(v) => onManagerTypeChange(v, createForm, setCreateManagerType)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{createManagerType === WarehouseManagerType.PARTNER && createCityId && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={partners.length ? '选择合伙人' : '该城市暂无合伙人'}
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<FulfillmentFields
|
||||
mode={createFulfillmentMode}
|
||||
providerOptions={providerOptions}
|
||||
onModeChange={setCreateFulfillmentMode}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑仓库"
|
||||
open={editOpen}
|
||||
width={520}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!editRow) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/city-warehouses/${editRow.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
message.success('已更新');
|
||||
setEditOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
{editRow && (
|
||||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="所属城市">
|
||||
{editRow.cityName}({editRow.cityCode})
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={MANAGER_OPTIONS}
|
||||
onChange={(v) => onManagerTypeChange(v, editForm, setEditManagerType)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{editManagerType === WarehouseManagerType.PARTNER && editRow && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<FulfillmentFields
|
||||
mode={editFulfillmentMode}
|
||||
providerOptions={providerOptions}
|
||||
onModeChange={setEditFulfillmentMode}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,774 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Select, Space, Statistic, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import {
|
||||
request,
|
||||
type DashboardAnalytics,
|
||||
type DashboardStats,
|
||||
type DeployTriggerResult,
|
||||
type HqProfile,
|
||||
type Paginated,
|
||||
type SystemVersion,
|
||||
} from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
const DEPLOYED_BY_LABELS: Record<string, string> = {
|
||||
webhook: 'Webhook',
|
||||
manual: '手动脚本',
|
||||
admin: 'Admin 发布',
|
||||
};
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type PromoOption = { id: string; code: string; name: string };
|
||||
|
||||
type AnalyticsFilters = {
|
||||
range: [Dayjs, Dayjs];
|
||||
cityId?: string;
|
||||
promoCodeId?: string;
|
||||
partnerAccountId?: string;
|
||||
};
|
||||
|
||||
function buildAnalyticsQs(f: AnalyticsFilters) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('dateFrom', f.range[0].format('YYYY-MM-DD'));
|
||||
qs.set('dateTo', f.range[1].format('YYYY-MM-DD'));
|
||||
if (f.cityId) qs.set('cityId', f.cityId);
|
||||
if (f.promoCodeId) qs.set('promoCodeId', f.promoCodeId);
|
||||
if (f.partnerAccountId) qs.set('partnerAccountId', f.partnerAccountId);
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [version, setVersion] = useState<SystemVersion | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [versionLoading, setVersionLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
const [filterForm] = Form.useForm<{
|
||||
range: [Dayjs, Dayjs];
|
||||
cityId?: string;
|
||||
promoCodeId?: string;
|
||||
partnerAccountId?: string;
|
||||
}>();
|
||||
const [filters, setFilters] = useState<AnalyticsFilters>({
|
||||
range: [dayjs().subtract(29, 'day'), dayjs()],
|
||||
});
|
||||
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
|
||||
const [analyticsLoading, setAnalyticsLoading] = useState(true);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [promos, setPromos] = useState<PromoOption[]>([]);
|
||||
const [partners, setPartners] = useState<Array<{ id: string; companyName?: string | null; name: string }>>([]);
|
||||
|
||||
const loadVersion = useCallback(() => {
|
||||
setVersionLoading(true);
|
||||
return request<SystemVersion | null>('/admin/dashboard/version')
|
||||
.then(setVersion)
|
||||
.catch(() => setVersion(null))
|
||||
.finally(() => setVersionLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadAnalytics = useCallback((f: AnalyticsFilters) => {
|
||||
setAnalyticsLoading(true);
|
||||
return request<DashboardAnalytics>(`/admin/dashboard/analytics?${buildAnalyticsQs(f)}`)
|
||||
.then(setAnalytics)
|
||||
.catch((e) => {
|
||||
message.error(e instanceof Error ? e.message : '加载统计失败');
|
||||
setAnalytics(null);
|
||||
})
|
||||
.finally(() => setAnalyticsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
request<DashboardStats>('/admin/dashboard/stats')
|
||||
.then(setStats)
|
||||
.finally(() => setLoading(false));
|
||||
request<HqProfile>('/admin/auth/me')
|
||||
.then((p) => {
|
||||
setProfile(p);
|
||||
if (p.adminRole === 'SUPER_ADMIN') {
|
||||
void loadVersion();
|
||||
} else {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setVersionLoading(false);
|
||||
});
|
||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setCities(res.items ?? []))
|
||||
.catch(() => setCities([]));
|
||||
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setPromos(res.items ?? []))
|
||||
.catch(() => setPromos([]));
|
||||
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
|
||||
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((res) => setPartners(res.items ?? []))
|
||||
.catch(() => setPartners([]));
|
||||
}, [loadVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAnalytics(filters);
|
||||
}, [filters, loadAnalytics]);
|
||||
|
||||
function applyFilters(values: {
|
||||
range?: [Dayjs, Dayjs];
|
||||
cityId?: string;
|
||||
promoCodeId?: string;
|
||||
partnerAccountId?: string;
|
||||
}) {
|
||||
const next: AnalyticsFilters = {
|
||||
range: values.range ?? filters.range,
|
||||
cityId: values.cityId || undefined,
|
||||
promoCodeId: values.promoCodeId || undefined,
|
||||
partnerAccountId: values.partnerAccountId || undefined,
|
||||
};
|
||||
setFilters(next);
|
||||
}
|
||||
|
||||
function setCityFilter(cityId: string) {
|
||||
const next = { ...filters, cityId: cityId === filters.cityId ? undefined : cityId };
|
||||
filterForm.setFieldsValue({ cityId: next.cityId });
|
||||
setFilters(next);
|
||||
}
|
||||
|
||||
function setPromoFilter(promoCodeId: string) {
|
||||
const key = promoCodeId === 'null' || promoCodeId === '' ? 'none' : promoCodeId;
|
||||
const next = {
|
||||
...filters,
|
||||
promoCodeId: key === filters.promoCodeId ? undefined : key,
|
||||
};
|
||||
filterForm.setFieldsValue({ promoCodeId: next.promoCodeId });
|
||||
setFilters(next);
|
||||
}
|
||||
|
||||
function setPartnerFilter(partnerAccountId: string) {
|
||||
const next = {
|
||||
...filters,
|
||||
partnerAccountId:
|
||||
partnerAccountId === filters.partnerAccountId ? undefined : partnerAccountId,
|
||||
};
|
||||
filterForm.setFieldsValue({ partnerAccountId: next.partnerAccountId });
|
||||
setFilters(next);
|
||||
}
|
||||
|
||||
function handleDeploy() {
|
||||
Modal.confirm({
|
||||
title: '确认发布更新?',
|
||||
content: '将触发服务器 webhook,拉取 auto-release.env 中配置的 GIT_BRANCH 并执行发版。发版通常需要数分钟,完成后可刷新版本信息。',
|
||||
okText: '开始发布',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setDeploying(true);
|
||||
try {
|
||||
const result = await request<DeployTriggerResult>('/admin/deploy/trigger', { method: 'POST' });
|
||||
message.success(result.message || '已触发发布');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '触发发布失败');
|
||||
throw e;
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
|
||||
|
||||
const ordersDrillQs = useMemo(() => {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('createdFrom', filters.range[0].format('YYYY-MM-DD'));
|
||||
qs.set('createdTo', filters.range[1].format('YYYY-MM-DD'));
|
||||
if (filters.cityId && filters.cityId !== 'none') qs.set('cityId', filters.cityId);
|
||||
return qs.toString();
|
||||
}, [filters]);
|
||||
|
||||
const byDateOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byDate ?? [];
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新增用户', '订单数'] },
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.date.slice(5)),
|
||||
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
|
||||
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
|
||||
],
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
const byCityOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byCity ?? [];
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['用户', '订单'] },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.cityName),
|
||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
||||
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
||||
],
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
const byPromoOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byPromo ?? [];
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['用户', '订单'] },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 64 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.name || r.code),
|
||||
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
||||
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
||||
],
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
const opsByDateOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byDate ?? [];
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] },
|
||||
grid: { left: 48, right: 48, top: 48, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.date.slice(5)),
|
||||
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '数量', minInterval: 1 },
|
||||
{ type: 'value', name: '金额', minInterval: 1 },
|
||||
],
|
||||
series: [
|
||||
{ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) },
|
||||
{ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) },
|
||||
{ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) },
|
||||
{
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
const opsByCityOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byCity ?? [];
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['合伙人', '门店', '核销笔数'] },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.cityName),
|
||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 },
|
||||
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
||||
],
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
const opsByPartnerOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byPartner ?? [];
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['门店', '核销笔数', '核销金额'] },
|
||||
grid: { left: 48, right: 48, top: 40, bottom: 64 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.companyName),
|
||||
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '数量', minInterval: 1 },
|
||||
{ type: 'value', name: '金额' },
|
||||
],
|
||||
series: [
|
||||
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
||||
{
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
const redeemByCityOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byCity ?? [];
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['核销笔数', '核销金额'] },
|
||||
grid: { left: 48, right: 48, top: 40, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.cityName),
|
||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '笔数', minInterval: 1 },
|
||||
{ type: 'value', name: '金额' },
|
||||
],
|
||||
series: [
|
||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 36 },
|
||||
{
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||
|
||||
{isSuperAdmin ? (
|
||||
<Card
|
||||
title="系统版本"
|
||||
loading={versionLoading}
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
|
||||
刷新版本
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={deploying}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
发布更新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{version ? (
|
||||
<Descriptions column={{ xs: 1, sm: 2, lg: 3 }} size="small">
|
||||
<Descriptions.Item label="分支">{version.branch || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Tag">{version.gitTag || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Commit">
|
||||
<Typography.Text copyable={{ text: version.commitId }}>{shortSha}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交说明" span={3}>{version.commitMessage}</Descriptions.Item>
|
||||
<Descriptions.Item label="发布时间">{fmtTime(version.deployedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="触发来源">
|
||||
{DEPLOYED_BY_LABELS[version.deployedBy || ''] || version.deployedBy || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Typography.Text type="secondary">尚未记录发版信息</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="数据统计筛选"
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
size="small"
|
||||
onClick={() => void loadAnalytics(filters)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
initialValues={{ range: filters.range }}
|
||||
onFinish={applyFilters}
|
||||
>
|
||||
<Form.Item name="range" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<DatePicker.RangePicker allowClear={false} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部开城"
|
||||
style={{ width: 160 }}
|
||||
options={[
|
||||
{ value: 'none', label: '未选城' },
|
||||
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="promoCodeId" label="推广码">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部来源"
|
||||
style={{ width: 200 }}
|
||||
options={[
|
||||
{ value: 'none', label: '自然量 / 无推广码' },
|
||||
...promos.map((p) => ({ value: p.id, label: `${p.name}(${p.code})` })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerAccountId" label="合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部合伙人"
|
||||
style={{ width: 200 }}
|
||||
options={partners.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.companyName || p.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="用户 / 订单统计"
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Link to={`/orders?${ordersDrillQs}`}>查看订单</Link>
|
||||
<Link to="/users">查看用户</Link>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24}>
|
||||
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
|
||||
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按城市(点击柱联动筛选)"
|
||||
loading={analyticsLoading}
|
||||
size="small"
|
||||
>
|
||||
<ReactECharts
|
||||
option={byCityOption}
|
||||
style={{ height: 300 }}
|
||||
notMerge
|
||||
onEvents={{
|
||||
click: (params: { name?: string }) => {
|
||||
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
||||
if (row) setCityFilter(row.cityId);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按推广码(点击柱联动筛选)"
|
||||
loading={analyticsLoading}
|
||||
size="small"
|
||||
>
|
||||
<ReactECharts
|
||||
option={byPromoOption}
|
||||
style={{ height: 300 }}
|
||||
notMerge
|
||||
onEvents={{
|
||||
click: (params: { name?: string }) => {
|
||||
const row = (analytics?.byPromo ?? []).find(
|
||||
(r) => (r.name || r.code) === params.name,
|
||||
);
|
||||
if (row) setPromoFilter(row.promoCodeId ?? 'none');
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="合伙人 / 门店 / 核销统计"
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Link to="/city-partners">查看合伙人</Link>
|
||||
<Link to="/stores">查看门店</Link>
|
||||
<Link to="/redeem-records">查看核销</Link>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
|
||||
</Col>
|
||||
<Col xs={24} sm={6}>
|
||||
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24}>
|
||||
<Card type="inner" title="按日趋势(合伙人 / 门店 / 核销)" loading={analyticsLoading} size="small">
|
||||
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按城市(点击柱联动筛选)"
|
||||
loading={analyticsLoading}
|
||||
size="small"
|
||||
>
|
||||
<ReactECharts
|
||||
option={opsByCityOption}
|
||||
style={{ height: 300 }}
|
||||
notMerge
|
||||
onEvents={{
|
||||
click: (params: { name?: string }) => {
|
||||
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
||||
if (row) setCityFilter(row.cityId);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按城市核销金额"
|
||||
loading={analyticsLoading}
|
||||
size="small"
|
||||
>
|
||||
<ReactECharts
|
||||
option={redeemByCityOption}
|
||||
style={{ height: 300 }}
|
||||
notMerge
|
||||
onEvents={{
|
||||
click: (params: { name?: string }) => {
|
||||
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
||||
if (row) setCityFilter(row.cityId);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按合伙人(门店 / 核销,点击联动筛选)"
|
||||
loading={analyticsLoading}
|
||||
size="small"
|
||||
>
|
||||
<ReactECharts
|
||||
option={opsByPartnerOption}
|
||||
style={{ height: 320 }}
|
||||
notMerge
|
||||
onEvents={{
|
||||
click: (params: { name?: string }) => {
|
||||
const row = (analytics?.byPartner ?? []).find(
|
||||
(r) => r.companyName === params.name,
|
||||
);
|
||||
if (row) setPartnerFilter(row.partnerAccountId);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
title="待审核合伙人打款"
|
||||
extra={<Link to="/finance/partner-bills">去处理</Link>}
|
||||
>
|
||||
<Statistic
|
||||
value={stats?.pendingBills ?? 0}
|
||||
suffix="笔"
|
||||
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
合伙人已确认申请,待总部通过或驳回
|
||||
{(stats?.pendingPartnerDraftBills ?? 0) > 0
|
||||
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
|
||||
: ''}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
title="待打款门店结算"
|
||||
extra={<Link to="/finance/store-bills">去处理</Link>}
|
||||
>
|
||||
<Statistic
|
||||
value={stats?.pendingPayouts ?? 0}
|
||||
suffix="笔"
|
||||
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
title="待审门店提现"
|
||||
extra={<Link to="/finance/store-bills?kind=WITHDRAW">去处理</Link>}
|
||||
>
|
||||
<Statistic
|
||||
value={stats?.pendingStoreWithdrawals ?? 0}
|
||||
suffix="笔"
|
||||
valueStyle={{
|
||||
color: (stats?.overdueStoreWithdrawals ?? 0) > 0 ? '#cf1322' : (stats?.pendingStoreWithdrawals ?? 0) > 0 ? '#fa8c16' : undefined,
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{(stats?.overdueStoreWithdrawals ?? 0) > 0
|
||||
? `超时未审 ${stats?.overdueStoreWithdrawals} 笔(FIN-003)`
|
||||
: '工作日 T+0 审完'}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card loading={loading} title="待处理工单">
|
||||
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="已合并访客账号" loading={loading}>
|
||||
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="订单状态分布" loading={loading}>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="status"
|
||||
dataSource={stats?.ordersByStatus ?? []}
|
||||
columns={[
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s: string) => STATUS_LABELS[s] || s,
|
||||
},
|
||||
{ title: '数量', dataIndex: 'count' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
|
||||
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
|
||||
};
|
||||
|
||||
export default function DeliveriesPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/deliveries',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.provider) qs.set('provider', filters.provider);
|
||||
if (filters.trackingNo) qs.set('trackingNo', filters.trackingNo);
|
||||
if (filters.orderNo) qs.set('orderNo', filters.orderNo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" onClick={async () => {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/deliveries/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
</Space>
|
||||
}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
status?: string | null;
|
||||
param1?: string | null;
|
||||
param2?: string | null;
|
||||
param3?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const EVENT_OPTIONS = [
|
||||
{ value: '', label: '全部领域事件' },
|
||||
{ value: 'ORDER_STATUS', label: '订单状态' },
|
||||
{ value: 'BENEFIT_LEDGER', label: '权益流水' },
|
||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||
{ value: 'TICKET_COLLAB', label: '工单协作' },
|
||||
{ value: 'PROMO_TOUCH', label: '推广触达' },
|
||||
];
|
||||
|
||||
export default function DomainEventsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const eventType = params.get('eventType') ?? '';
|
||||
const refType = params.get('refType') ?? '';
|
||||
const refId = params.get('refId') ?? '';
|
||||
const [data, setData] = useState<{ items: Row[]; total: number; page: number; pageSize: number } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
const q = new URLSearchParams();
|
||||
if (eventType) q.set('eventType', eventType);
|
||||
if (refType) q.set('refType', refType);
|
||||
if (refId) q.set('refId', refId);
|
||||
q.set('page', params.get('page') ?? '1');
|
||||
q.set('pageSize', '20');
|
||||
request<{ items: Row[]; total: number; page: number; pageSize: number }>(
|
||||
`/admin/logs/domain-events?${q.toString()}`,
|
||||
)
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
}, [eventType, refType, refId, params]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
||||
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
|
||||
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<a
|
||||
onClick={async () => {
|
||||
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="领域事件">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={eventType}
|
||||
options={EVENT_OPTIONS}
|
||||
onChange={(v) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (v) next.set('eventType', v);
|
||||
else next.delete('eventType');
|
||||
next.set('page', '1');
|
||||
setParams(next);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: data?.page ?? 1,
|
||||
pageSize: data?.pageSize ?? 20,
|
||||
total: data?.total ?? 0,
|
||||
onChange: (p) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('page', String(p));
|
||||
setParams(next);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer open={!!detail} title="事件详情" width={520} onClose={() => setDetail(null)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
|
||||
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
|
||||
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||
FULFILLMENT_PROVIDER_STATUS_LABELS,
|
||||
FULFILLMENT_PROVIDER_TYPE_LABELS,
|
||||
FulfillmentProviderStatus,
|
||||
FulfillmentProviderType,
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||||
LogisticsSettlementMethod,
|
||||
isXfxProviderCode,
|
||||
type FulfillmentProviderDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
const SIGN_OPTIONS = [
|
||||
{ value: 'MD5', label: 'MD5' },
|
||||
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
|
||||
];
|
||||
|
||||
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
||||
|
||||
export default function FulfillmentProvidersPage() {
|
||||
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const watchedCode = Form.useWatch('code', form);
|
||||
const watchedType = Form.useWatch('type', form);
|
||||
|
||||
const showXfxFields = useMemo(
|
||||
() => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API,
|
||||
[watchedCode, watchedType],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||||
setRows(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function openCreate() {
|
||||
setEditRow(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
code: 'XFX',
|
||||
name: '小飞侠',
|
||||
type: FulfillmentProviderType.API,
|
||||
status: FulfillmentProviderStatus.ACTIVE,
|
||||
apiUrl: DEFAULT_XFX_API_URL,
|
||||
signType: 'MD5',
|
||||
settlementMethod: LogisticsSettlementMethod.PREPAID,
|
||||
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||||
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: FulfillmentProviderDto) {
|
||||
setEditRow(row);
|
||||
const xfx = row.xiaofeixiaConfig;
|
||||
const pricing = row.pricingRules;
|
||||
form.setFieldsValue({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL,
|
||||
mchId: xfx?.mchId || '',
|
||||
apiKey: '',
|
||||
signType: xfx?.signType || 'MD5',
|
||||
appId: xfx?.appId || '',
|
||||
bankAccountName: row.bankAccountName || '',
|
||||
bankName: row.bankName || '',
|
||||
bankBranch: row.bankBranch || '',
|
||||
bankAccountNo: row.bankAccountNo || '',
|
||||
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
|
||||
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||||
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const v = await form.validateFields();
|
||||
const payload: Record<string, unknown> = {
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
status: v.status,
|
||||
bankAccountName: v.bankAccountName || null,
|
||||
bankName: v.bankName || null,
|
||||
bankBranch: v.bankBranch || null,
|
||||
bankAccountNo: v.bankAccountNo || null,
|
||||
settlementMethod: v.settlementMethod,
|
||||
pricingRules: {
|
||||
baseBottles: Number(v.baseBottles),
|
||||
baseFee: Number(v.baseFee),
|
||||
extraBottleFee: Number(v.extraBottleFee),
|
||||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||
payload.xiaofeixiaConfig = {
|
||||
apiUrl: v.apiUrl,
|
||||
mchId: v.mchId,
|
||||
apiKey: v.apiKey || undefined,
|
||||
signType: v.signType,
|
||||
appId: v.appId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (editRow) {
|
||||
await request(`/admin/fulfillment-providers/${editRow.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await request('/admin/fulfillment-providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: v.code,
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setOpen(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FulfillmentProviderDto> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
|
||||
},
|
||||
{
|
||||
title: '结算',
|
||||
dataIndex: 'settlementMethod',
|
||||
width: 100,
|
||||
render: (v) =>
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
|
||||
},
|
||||
{
|
||||
title: '充值余额',
|
||||
dataIndex: 'prepaidBalance',
|
||||
width: 100,
|
||||
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v) => (
|
||||
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
|
||||
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '接口配置',
|
||||
render: (_, row) => {
|
||||
if (isXfxProviderCode(row.code)) {
|
||||
const cfg = row.xiaofeixiaConfig;
|
||||
if (!cfg?.apiUrl) return <Tag>未配置</Tag>;
|
||||
return (
|
||||
<span title={cfg.apiUrl}>
|
||||
{cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return row.hasConfig ? '已配置' : '—';
|
||||
},
|
||||
},
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
仓配管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
注册承运商
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||||
/>
|
||||
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title={editRow ? '编辑承运商' : '注册承运商'}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
||||
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 小飞侠、京东物流" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">物流对账</Divider>
|
||||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||
<Select options={SETTLEMENT_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="收款户名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankName" label="开户银行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户支行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="银行账号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="baseBottles"
|
||||
label="起送瓶数"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="baseFee"
|
||||
label="起送费用(元)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="extraBottleFee"
|
||||
label="加一瓶(元)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
|
||||
<InputNumber min={1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
{showXfxFields && (
|
||||
<>
|
||||
<Divider orientation="left">小飞侠接口参数</Divider>
|
||||
<Form.Item
|
||||
name="apiUrl"
|
||||
label="API 地址"
|
||||
rules={[{ required: true, message: '请填写小飞侠 API 地址' }]}
|
||||
extra="推单请求将发往此地址"
|
||||
>
|
||||
<Input placeholder={DEFAULT_XFX_API_URL} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="mchId"
|
||||
label="商户号"
|
||||
rules={[{ required: true, message: '请填写商户号' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
label="API Key"
|
||||
rules={editRow ? [] : [{ required: true, message: '请填写 API Key' }]}
|
||||
extra={
|
||||
editRow?.xiaofeixiaConfig?.hasApiKey
|
||||
? '已配置密钥;留空则保持不变'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input.Password placeholder={editRow ? '留空则不修改' : '请输入'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="signType" label="签名类型" initialValue="MD5">
|
||||
<Select options={SIGN_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="appId" label="AppID(可选)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
phone: string;
|
||||
loginName: string | null;
|
||||
hasPassword: boolean;
|
||||
name: string;
|
||||
adminRole: string;
|
||||
status: string;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
OPS: '运营',
|
||||
FINANCE: '财务',
|
||||
CUSTOMER_SERVICE: '客服',
|
||||
};
|
||||
|
||||
export default function HqAccountsPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/hq-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.adminRole) qs.set('adminRole', filters.adminRole);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '登录方式',
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
{r.hasPassword ? <Tag color="blue">密码</Tag> : null}
|
||||
<Tag>短信</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" disabled={!isSuperAdmin} onClick={() => {
|
||||
setDetail(row);
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
loginName: row.loginName,
|
||||
adminRole: row.adminRole,
|
||||
status: row.status,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' });
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Button type="primary" onClick={async () => {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const payload = { ...v };
|
||||
if (!payload.password) delete payload.password;
|
||||
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="loginName" label="用户名"><Input placeholder="用于密码登录" /></Form.Item>
|
||||
<Form.Item name="password" label="新密码" extra="留空则不修改">
|
||||
<Input.Password placeholder="至少 6 位" />
|
||||
</Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Modal
|
||||
title="新建 HQ 账户"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}>
|
||||
<Form.Item name="credentialType" label="创建方式">
|
||||
<Radio.Group>
|
||||
<Radio value="phone">手机号账户(短信登录)</Radio>
|
||||
<Radio value="password">用户名密码账户</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
{credentialType === 'phone' ? (
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item name="loginName" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '至少 6 位' }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号(可选)" extra="不填则自动生成占位手机号,用于满足账号唯一约束">
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
hqAccountId: string | null;
|
||||
hqName: string | null;
|
||||
hqPhone: string | null;
|
||||
hqRole: string | null;
|
||||
action: string | null;
|
||||
actionLabel: string;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
status: string | null;
|
||||
remark: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function HqLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
hqAccountId: searchParams.get('hqAccountId') ?? '',
|
||||
action: searchParams.get('action') ?? '',
|
||||
refType: searchParams.get('refType') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/logs/hq',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId);
|
||||
if (filters.action) qs.set('action', filters.action);
|
||||
if (filters.refType) qs.set('refType', filters.refType);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作人',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.hqName}
|
||||
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '行为',
|
||||
dataIndex: 'actionLabel',
|
||||
width: 160,
|
||||
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
|
||||
},
|
||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
const res = await request<Row>(`/admin/logs/hq/${row.id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>HQ 操作日志</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
const qs = new URLSearchParams();
|
||||
if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId);
|
||||
if (values.action) qs.set('action', values.action);
|
||||
if (values.refType) qs.set('refType', values.refType);
|
||||
setSearchParams(qs);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="hqAccountId" label="HQ 账户 ID">
|
||||
<Input allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="action" label="行为">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 180 }}
|
||||
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="refType" label="对象类型">
|
||||
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({ hqAccountId: '', action: '', refType: '' });
|
||||
setSearchParams({});
|
||||
setPage(1);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人">
|
||||
<AdminCellLine
|
||||
primary={detail.hqName}
|
||||
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="行为">
|
||||
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
||||
<pre style={{
|
||||
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||
maxHeight: 400, overflow: 'auto', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(detail.detail, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
HQ_ADMIN_ROLES,
|
||||
HQ_PERMISSION_CATALOG,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
|
||||
type RolePermRes = { role: string; permissionKeys: string[] };
|
||||
type AccountOption = { id: string; name: string; phone: string; loginName: string | null; adminRole: string };
|
||||
type AccountPermRes = {
|
||||
account: AccountOption;
|
||||
permissionKeys: string[];
|
||||
rolePermissionKeys: string[];
|
||||
userPermissionKeys: string[];
|
||||
effectivePermissionKeys: string[];
|
||||
};
|
||||
|
||||
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||||
|
||||
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
|
||||
|
||||
function groupColSpan(itemCount: number): number {
|
||||
if (itemCount <= 2) return 12;
|
||||
if (itemCount === 3) return 8;
|
||||
if (itemCount === 4) return 6;
|
||||
return 8;
|
||||
}
|
||||
|
||||
function PermissionChecklist({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (keys: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Checkbox.Group
|
||||
style={{ width: '100%' }}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(checked) => onChange(checked as string[])}
|
||||
>
|
||||
{CATALOG_GROUPS.map((group) => {
|
||||
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
|
||||
const span = groupColSpan(items.length);
|
||||
const compact = items.length <= 3;
|
||||
return (
|
||||
<div
|
||||
key={group}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
|
||||
{group}
|
||||
</Typography.Text>
|
||||
{compact ? (
|
||||
<Space size={[24, 8]} wrap>
|
||||
{items.map((item) => (
|
||||
<Checkbox key={item.key} value={item.key}>
|
||||
{item.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Row gutter={[12, 10]}>
|
||||
{items.map((item) => (
|
||||
<Col key={item.key} xs={24} sm={12} md={span}>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Checkbox.Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HqPermissionsPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [role, setRole] = useState<string>('OPS');
|
||||
const [roleKeys, setRoleKeys] = useState<string[]>([]);
|
||||
const [roleLoading, setRoleLoading] = useState(false);
|
||||
const [roleSaving, setRoleSaving] = useState(false);
|
||||
|
||||
const [accounts, setAccounts] = useState<AccountOption[]>([]);
|
||||
const [accountId, setAccountId] = useState<string>();
|
||||
const [accountKeys, setAccountKeys] = useState<string[]>([]);
|
||||
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
|
||||
const [accountLoading, setAccountLoading] = useState(false);
|
||||
const [accountSaving, setAccountSaving] = useState(false);
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => accounts.find((a) => a.id === accountId),
|
||||
[accounts, accountId],
|
||||
);
|
||||
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
const previewEffectiveKeys = useMemo(
|
||||
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
||||
[roleInheritedKeys, accountKeys],
|
||||
);
|
||||
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin) return;
|
||||
request<{ items: AccountOption[] }>('/admin/hq-accounts?page=1&pageSize=100')
|
||||
.then((res) => setAccounts(res.items))
|
||||
.catch(() => {});
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin || !role) return;
|
||||
setRoleLoading(true);
|
||||
request<RolePermRes>(`/admin/hq-permissions/roles/${role}`)
|
||||
.then((res) => setRoleKeys(res.permissionKeys))
|
||||
.finally(() => setRoleLoading(false));
|
||||
}, [isSuperAdmin, role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin || !accountId) return;
|
||||
setAccountLoading(true);
|
||||
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
||||
.then((res) => {
|
||||
setAccountKeys(res.userPermissionKeys);
|
||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||
})
|
||||
.finally(() => setAccountLoading(false));
|
||||
}, [isSuperAdmin, accountId]);
|
||||
|
||||
async function saveRolePermissions() {
|
||||
setRoleSaving(true);
|
||||
try {
|
||||
const res = await request<RolePermRes>(`/admin/hq-permissions/roles/${role}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ permissionKeys: roleKeys }),
|
||||
});
|
||||
setRoleKeys(res.permissionKeys);
|
||||
message.success('角色权限已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setRoleSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccountPermissions() {
|
||||
if (!accountId) return;
|
||||
setAccountSaving(true);
|
||||
try {
|
||||
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ permissionKeys: accountKeys }),
|
||||
});
|
||||
setAccountKeys(res.userPermissionKeys);
|
||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||
message.success('用户权限已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setAccountSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||
<Alert type="warning" showIcon message="仅超级管理员可配置权限" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。
|
||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'role',
|
||||
label: '按角色分配',
|
||||
children: (
|
||||
<Card loading={roleLoading}>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="角色">
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
options={HQ_ADMIN_ROLES.map((r) => ({
|
||||
value: r.value,
|
||||
label: r.label,
|
||||
disabled: r.value === 'SUPER_ADMIN',
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{role === 'SUPER_ADMIN' ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" loading={roleSaving} onClick={() => void saveRolePermissions()}>
|
||||
保存角色权限
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: '按用户分配',
|
||||
children: (
|
||||
<Card loading={accountLoading}>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="HQ 账户">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="选择账户"
|
||||
style={{ width: 320 }}
|
||||
value={accountId}
|
||||
onChange={setAccountId}
|
||||
optionFilterProp="label"
|
||||
options={accounts.map((a) => ({
|
||||
value: a.id,
|
||||
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{!accountId ? (
|
||||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>角色继承:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Tag color="blue">超级管理员基础权限(不含危险操作)</Tag>
|
||||
) : (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{roleInheritedKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||
return (
|
||||
<Tag key={key} color="blue">
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{!roleInheritedKeys.length ? (
|
||||
<Typography.Text type="secondary">无</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。
|
||||
</Typography.Paragraph>
|
||||
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>合并生效:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Space wrap size={[4, 4]}>
|
||||
<Tag>基础权限(全部)</Tag>
|
||||
{accountKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key} color="orange">
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
) : (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{previewEffectiveKeys.map((key) => {
|
||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||
return (
|
||||
<Tag key={key}>
|
||||
{item?.label || key}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
||||
保存用户权限
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_STATUS_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceKind,
|
||||
type InvoiceStatus,
|
||||
type InvoiceTitleType,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
invoiceNo: string;
|
||||
orderNo?: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
titleName: string;
|
||||
status: InvoiceStatus;
|
||||
overdue?: boolean;
|
||||
createdAt: string;
|
||||
fileUrl?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
payAmount?: string;
|
||||
userPhone?: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
type CreateFormValues = {
|
||||
orderNo: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/invoices',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm] = Form.useForm<CreateFormValues>();
|
||||
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||
const titleType = Form.useWatch('titleType', createForm);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetail(await request(`/admin/invoices/${id}`));
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function issueWithFile(file: File) {
|
||||
if (!detail) return false;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
|
||||
await request(`/admin/invoices/${detail.id}/issue`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ fileUrl: uploaded.url }),
|
||||
});
|
||||
message.success('已开票回传');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '开票失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!detail) return;
|
||||
await request(`/admin/invoices/${detail.id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '驳回' }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
await request('/admin/invoices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderNo: values.orderNo.trim(),
|
||||
titleType: values.titleType,
|
||||
invoiceKind: values.invoiceKind,
|
||||
titleName: values.titleName.trim(),
|
||||
taxNo: values.taxNo?.trim() || undefined,
|
||||
addressPhone: values.addressPhone?.trim() || undefined,
|
||||
bankAccount: values.bankAccount?.trim() || undefined,
|
||||
email: values.email.trim(),
|
||||
phone: values.phone.trim(),
|
||||
remark: values.remark?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('发票申请已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{
|
||||
title: '抬头',
|
||||
width: 100,
|
||||
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
|
||||
},
|
||||
{
|
||||
title: '票种',
|
||||
width: 120,
|
||||
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||
},
|
||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<>
|
||||
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
|
||||
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
|
||||
</Tag>
|
||||
{r.overdue ? <Tag color="red">超时</Tag> : null}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
发票管理
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.setFieldsValue({
|
||||
titleType: 'PERSONAL',
|
||||
invoiceKind: 'NORMAL',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
创建发票申请
|
||||
</Button>
|
||||
</div>
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'PENDING', label: '待开票' },
|
||||
{ value: 'ISSUED', label: '已开票' },
|
||||
{ value: 'REJECTED', label: '已驳回' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1000 }}
|
||||
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title="发票详情"
|
||||
width={520}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' ? (
|
||||
<Space>
|
||||
<Upload
|
||||
accept="image/*,.pdf"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
void issueWithFile(file);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button type="primary" loading={uploading}>
|
||||
上传并开票
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button danger onClick={() => void reject()}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="抬头类型">
|
||||
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发票类型">
|
||||
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{INVOICE_STATUS_LABELS[detail.status]}
|
||||
{detail.overdue ? '(超 2 工作日)' : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发票文件">
|
||||
{detail.fileUrl ? (
|
||||
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
|
||||
查看/下载
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark ?? '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="创建发票申请"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => void submitCreate()}
|
||||
confirmLoading={creating}
|
||||
destroyOnClose
|
||||
okText="提交"
|
||||
width={520}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, message: '请填写已完成订单号' }]}
|
||||
extra="仅已完成订单可开票"
|
||||
>
|
||||
<Input placeholder="订单号" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="invoiceKind"
|
||||
label="发票类型"
|
||||
rules={[{ required: true, message: '请选择发票类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => ({
|
||||
value: k,
|
||||
label: INVOICE_KIND_LABELS[k],
|
||||
}))}
|
||||
onChange={(k: InvoiceKind) => {
|
||||
if (k === 'SPECIAL') createForm.setFieldValue('titleType', 'ENTERPRISE');
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="titleType"
|
||||
label="抬头类型"
|
||||
rules={[{ required: true, message: '请选择抬头类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => ({
|
||||
value: t,
|
||||
label: INVOICE_TITLE_TYPE_LABELS[t],
|
||||
disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL',
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="titleName"
|
||||
label="抬头名称"
|
||||
rules={[{ required: true, message: '请填写抬头名称' }]}
|
||||
>
|
||||
<Input placeholder="个人姓名或企业全称" />
|
||||
</Form.Item>
|
||||
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||
<Form.Item
|
||||
name="taxNo"
|
||||
label="税号"
|
||||
rules={[{ required: true, message: '企业抬头须填写税号' }]}
|
||||
>
|
||||
<Input placeholder="纳税人识别号" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{invoiceKind === 'SPECIAL' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="addressPhone"
|
||||
label="地址电话"
|
||||
rules={[{ required: true, message: '专用发票须填写地址电话' }]}
|
||||
>
|
||||
<Input placeholder="注册地址及电话" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankAccount"
|
||||
label="开户行账号"
|
||||
rules={[{ required: true, message: '专用发票须填写开户行账号' }]}
|
||||
>
|
||||
<Input placeholder="开户行及账号" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="接收邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请填写邮箱' },
|
||||
{ type: 'email', message: '邮箱格式不正确' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="发票发送邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[{ required: true, message: '请填写手机号' }]}
|
||||
>
|
||||
<Input placeholder="联系手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
KnowledgeBaseDto,
|
||||
KnowledgeDocumentDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type DocForm = {
|
||||
title: string;
|
||||
contentText?: string;
|
||||
};
|
||||
|
||||
export default function KnowledgeBasesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [docForm] = Form.useForm<DocForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<KnowledgeBaseDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [drawerKb, setDrawerKb] = useState<KnowledgeBaseDto | null>(null);
|
||||
const [docs, setDocs] = useState<KnowledgeDocumentDto[]>([]);
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [docModalOpen, setDocModalOpen] = useState(false);
|
||||
const [docSaving, setDocSaving] = useState(false);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<KnowledgeBaseDto>(
|
||||
'/admin/knowledge-bases',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const loadDocs = async (kb: KnowledgeBaseDto) => {
|
||||
setDrawerKb(kb);
|
||||
setDocsLoading(true);
|
||||
try {
|
||||
const res = await request<{ items: KnowledgeDocumentDto[] }>(
|
||||
`/admin/knowledge-bases/${kb.id}/documents`,
|
||||
);
|
||||
setDocs(res.items);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDocsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ name: '', description: '', enabled: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: KnowledgeBaseDto) => {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await request(`/admin/knowledge-bases/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/knowledge-bases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<KnowledgeBaseDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="green">开</Tag> : <Tag>关</Tag>),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 100,
|
||||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => void loadDocs(row)}>
|
||||
文档
|
||||
</Button>
|
||||
{row.canEditFull ? (
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{row.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="确认删除知识库及全部文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) =>
|
||||
s === 'READY' ? <Tag color="green">可检索</Tag> : s === 'FAILED' ? <Tag color="red">失败</Tag> : <Tag>无正文</Tag>,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) =>
|
||||
drawerKb?.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="删除文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
message.success('已删除');
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
知识库
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) =>
|
||||
setFilters({
|
||||
name: v.name || '',
|
||||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建知识库
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑知识库' : '新建知识库'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void onSave()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={drawerKb ? `文档 · ${drawerKb.name}` : '文档'}
|
||||
open={!!drawerKb}
|
||||
width={720}
|
||||
onClose={() => setDrawerKb(null)}
|
||||
extra={
|
||||
drawerKb?.canEditFull ? (
|
||||
<Button type="primary" onClick={() => { docForm.resetFields(); setDocModalOpen(true); }}>
|
||||
上传/粘贴
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={docsLoading}
|
||||
columns={docColumns}
|
||||
dataSource={docs}
|
||||
pagination={false}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="添加知识文档"
|
||||
open={docModalOpen}
|
||||
onCancel={() => setDocModalOpen(false)}
|
||||
confirmLoading={docSaving}
|
||||
onOk={async () => {
|
||||
if (!drawerKb) return;
|
||||
const values = await docForm.validateFields();
|
||||
setDocSaving(true);
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: values.title,
|
||||
contentText: values.contentText || null,
|
||||
}),
|
||||
});
|
||||
message.success('已添加');
|
||||
setDocModalOpen(false);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDocSaving(false);
|
||||
}
|
||||
}}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={docForm} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contentText" label="正文(可粘贴)">
|
||||
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
|
||||
</Form.Item>
|
||||
<Form.Item label="或上传文本文件">
|
||||
<Upload
|
||||
accept=".txt,.md,.markdown,.csv,.json,.log,text/*"
|
||||
maxCount={1}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
if (!drawerKb) return;
|
||||
try {
|
||||
const raw = file as File;
|
||||
const uploaded = await uploadFileToOss(raw, {
|
||||
bizType: 'KB_DOCUMENT',
|
||||
mediaType: 'FILE',
|
||||
});
|
||||
const title =
|
||||
docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, '');
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
fileName: raw.name,
|
||||
fileUrl: uploaded.url,
|
||||
mimeType: raw.type || null,
|
||||
sizeBytes: raw.size,
|
||||
}),
|
||||
});
|
||||
message.success('上传成功');
|
||||
setDocModalOpen(false);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
onSuccess?.(uploaded);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
}
|
||||
}}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传到 OSS 并入库</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
LLM_PROVIDERS,
|
||||
LLM_PROVIDER_LABELS,
|
||||
LLM_PROVIDER_PRESETS,
|
||||
type LlmApiConfigDto,
|
||||
type LlmProvider,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function LlmConfigsPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LlmApiConfigDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const providerWatch = Form.useWatch('provider', form);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
|
||||
'/admin/llm-configs',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerWatch || editing) return;
|
||||
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
|
||||
if (!preset) return;
|
||||
form.setFieldsValue({
|
||||
baseUrl: preset.defaultBaseUrl || undefined,
|
||||
modelName: preset.defaultModel || undefined,
|
||||
});
|
||||
}, [providerWatch, editing, form]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
provider: 'DEEPSEEK',
|
||||
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
|
||||
apiKey: '',
|
||||
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1024,
|
||||
systemPrompt: '',
|
||||
enabled: true,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: LlmApiConfigDto) => {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
provider: row.provider,
|
||||
baseUrl: row.baseUrl,
|
||||
apiKey: '',
|
||||
modelName: row.modelName,
|
||||
temperature: row.temperature,
|
||||
maxTokens: row.maxTokens,
|
||||
systemPrompt: row.systemPrompt || '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
if (!editing.canEditFull) {
|
||||
await request(`/admin/llm-configs/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: values.enabled }),
|
||||
});
|
||||
} else {
|
||||
await request(`/admin/llm-configs/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey || undefined,
|
||||
modelName: values.modelName,
|
||||
temperature: values.temperature ?? null,
|
||||
maxTokens: values.maxTokens ?? null,
|
||||
systemPrompt: values.systemPrompt || null,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
});
|
||||
}
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/llm-configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey,
|
||||
modelName: values.modelName,
|
||||
temperature: values.temperature ?? null,
|
||||
maxTokens: values.maxTokens ?? null,
|
||||
systemPrompt: values.systemPrompt || null,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LlmApiConfigDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '提供商',
|
||||
dataIndex: 'provider',
|
||||
width: 120,
|
||||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'apiKeyConfigured',
|
||||
width: 80,
|
||||
render: (v: boolean) => (v ? <Tag color="green">已配</Tag> : <Tag>无</Tag>),
|
||||
},
|
||||
{
|
||||
title: '生效',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
disabled={!row.isOwner && !row.canEditFull}
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
await request(`/admin/llm-configs/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: checked }),
|
||||
});
|
||||
message.success(checked ? '已启用' : '已停用');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 100,
|
||||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
{row.canEditFull ? '编辑' : '开关'}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
message.success(res.reply || '测试成功');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
{row.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const fullEdit = !editing || editing.canEditFull;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
语言模型配置
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) =>
|
||||
setFilters({
|
||||
name: v.name || '',
|
||||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear placeholder="搜索" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="生效">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: true, label: '开' },
|
||||
{ value: false, label: '关' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void onSave()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
|
||||
<Input disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
|
||||
<Select
|
||||
disabled={!fullEdit}
|
||||
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="Base URL"
|
||||
rules={[{ required: fullEdit, message: '必填' }]}
|
||||
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1)"
|
||||
>
|
||||
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
label="API Key"
|
||||
rules={editing ? [] : [{ required: true, message: '必填' }]}
|
||||
extra={editing ? '留空表示不修改' : undefined}
|
||||
>
|
||||
<Input.Password disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
|
||||
<Input disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Space style={{ display: 'flex' }} size="large">
|
||||
<Form.Item name="temperature" label="Temperature">
|
||||
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxTokens" label="Max Tokens">
|
||||
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="systemPrompt" label="系统提示词">
|
||||
<Input.TextArea disabled={!fullEdit} rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="生效" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
|
||||
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
|
||||
import { saveAuth, request } from '../lib/api';
|
||||
|
||||
type LoginResult = { accessToken: string; refreshToken: string };
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [smsForm] = Form.useForm();
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const phone = Form.useWatch('phone', smsForm);
|
||||
|
||||
async function sendCode() {
|
||||
if (!phone) {
|
||||
message.warning('请先输入手机号');
|
||||
return;
|
||||
}
|
||||
await request('/admin/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }),
|
||||
});
|
||||
message.success('验证码已发送');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function finishLogin(data: LoginResult) {
|
||||
saveAuth(data);
|
||||
message.success('登录成功');
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
async function onSmsFinish(values: { phone: string; code: string }) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<LoginResult>('/admin/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
await finishLogin(data);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onPasswordFinish(values: { loginName: string; password: string }) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<LoginResult>('/admin/auth/login/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
await finishLogin(data);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 400 }}>
|
||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
HQ 管理后台
|
||||
</Typography.Title>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'password',
|
||||
label: '账号密码',
|
||||
children: (
|
||||
<Form
|
||||
form={passwordForm}
|
||||
layout="vertical"
|
||||
onFinish={onPasswordFinish}
|
||||
initialValues={{ loginName: 'admin' }}
|
||||
>
|
||||
<Form.Item name="loginName" label="账号" rules={[{ required: true, message: '请输入账号' }]}>
|
||||
<Input placeholder="admin" autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sms',
|
||||
label: '短信验证码',
|
||||
children: (
|
||||
<Form
|
||||
form={smsForm}
|
||||
layout="vertical"
|
||||
onFinish={onSmsFinish}
|
||||
initialValues={{ phone: '13600000001', code: MOCK_SMS_FIXED_CODE }}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
<Input placeholder="13600000001" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||
<Input
|
||||
placeholder={MOCK_SMS_FIXED_CODE}
|
||||
addonAfter={
|
||||
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||||
type LogisticsSettlementMethod,
|
||||
} from '@dukang/shared-types';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type BillRow = {
|
||||
id: string;
|
||||
billNo: string;
|
||||
fulfillmentProviderId: string;
|
||||
providerCode?: string;
|
||||
providerName?: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
orderCount: number;
|
||||
bottleCount: number;
|
||||
logisticsAmount: number;
|
||||
settlementMethod: string;
|
||||
status: string;
|
||||
paidAt?: string | null;
|
||||
};
|
||||
|
||||
type BillItem = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
quantity: number;
|
||||
logisticsAmount: number;
|
||||
shippedAt: string;
|
||||
};
|
||||
|
||||
type ProviderSummary = {
|
||||
providerId: string;
|
||||
providerCode: string;
|
||||
providerName: string;
|
||||
settlementMethod: string;
|
||||
prepaidBalance: number;
|
||||
bankAccountName?: string | null;
|
||||
bankName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
orderCount: number;
|
||||
bottleCount: number;
|
||||
logisticsAmount: number;
|
||||
billId?: string | null;
|
||||
billStatus?: string | null;
|
||||
pricingRules?: {
|
||||
baseBottles: number;
|
||||
baseFee: number;
|
||||
extraBottleFee: number;
|
||||
boxBottles?: number;
|
||||
boxFee?: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未结算',
|
||||
PAID: '已结算',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
export default function LogisticsBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [summaryForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
|
||||
'/admin/logistics-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.providerId) qs.set('providerId', filters.providerId);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
|
||||
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
const [genForm] = Form.useForm();
|
||||
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||||
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
|
||||
const [rechargeForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
|
||||
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSummary(period: Dayjs) {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const res = await request<{ items: ProviderSummary[] }>(
|
||||
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
|
||||
);
|
||||
setSummaryRows(res.items ?? []);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary(summaryPeriod);
|
||||
}, [summaryPeriod]);
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认结算?',
|
||||
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
|
||||
okText: '确认结算',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/logistics-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已确认结算');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
void loadSummary(summaryPeriod);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-bills/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.providerId) qs.set('providerId', filters.providerId);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
const result = await request<{ csv: string; count: number }>(
|
||||
`/admin/logistics-bills/export?${qs}`,
|
||||
);
|
||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBills() {
|
||||
const v = await genForm.validateFields();
|
||||
const month = v.month as Dayjs;
|
||||
await request('/admin/logistics-bills/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
year: month.year(),
|
||||
month: month.month() + 1,
|
||||
providerId: v.providerId || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已生成物流对账单');
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
void loadSummary(summaryPeriod);
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
if (!rechargeTarget) return;
|
||||
const v = await rechargeForm.validateFields();
|
||||
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
|
||||
});
|
||||
message.success('充值成功');
|
||||
setRechargeOpen(false);
|
||||
void loadSummary(summaryPeriod);
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
||||
|
||||
const billColumns: ColumnsType<BillRow> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '承运商',
|
||||
width: 140,
|
||||
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
|
||||
},
|
||||
{
|
||||
title: '账期',
|
||||
width: 200,
|
||||
render: (_, r) =>
|
||||
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
||||
},
|
||||
{
|
||||
title: '结算方式',
|
||||
dataIndex: 'settlementMethod',
|
||||
width: 100,
|
||||
render: (v) =>
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||||
{
|
||||
title: '物流费',
|
||||
dataIndex: 'logisticsAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.status === 'UNPAID' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
|
||||
>
|
||||
确认结算
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const summaryColumns: ColumnsType<ProviderSummary> = [
|
||||
{
|
||||
title: '承运商',
|
||||
render: (_, r) => `${r.providerName} (${r.providerCode})`,
|
||||
},
|
||||
{
|
||||
title: '结算方式',
|
||||
dataIndex: 'settlementMethod',
|
||||
width: 110,
|
||||
render: (v) =>
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||||
},
|
||||
{
|
||||
title: '计价标准',
|
||||
width: 220,
|
||||
render: (_, r) => {
|
||||
const p = r.pricingRules;
|
||||
if (!p) return '—';
|
||||
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
|
||||
p.boxBottles ? `,${p.boxBottles}瓶箱¥${p.boxFee}` : ''
|
||||
}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '银行账户',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, r) =>
|
||||
r.bankAccountName
|
||||
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
||||
: '—',
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||||
{
|
||||
title: '应付物流费',
|
||||
dataIndex: 'logisticsAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '充值余额',
|
||||
dataIndex: 'prepaidBalance',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '账单',
|
||||
width: 100,
|
||||
render: (_, r) =>
|
||||
r.billStatus ? (
|
||||
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
|
||||
{STATUS_LABELS[r.billStatus] || r.billStatus}
|
||||
</Tag>
|
||||
) : (
|
||||
'未生成'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_, r) =>
|
||||
r.settlementMethod === 'PREPAID' ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setRechargeTarget(r);
|
||||
rechargeForm.resetFields();
|
||||
setRechargeOpen(true);
|
||||
}}
|
||||
>
|
||||
充值
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
物流对账
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'summary',
|
||||
label: '按承运商汇总',
|
||||
children: (
|
||||
<>
|
||||
<Form
|
||||
form={summaryForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
initialValues={{ month: summaryPeriod }}
|
||||
onFinish={(v: { month?: Dayjs }) => {
|
||||
const m = v.month || dayjs().subtract(1, 'month');
|
||||
setSummaryPeriod(m);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="month" label="账期">
|
||||
<DatePicker picker="month" allowClear={false} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="providerId"
|
||||
loading={summaryLoading}
|
||||
columns={summaryColumns}
|
||||
dataSource={summaryRows}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bills',
|
||||
label: '月账单',
|
||||
children: (
|
||||
<>
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic
|
||||
title="瓶数合计"
|
||||
value={summary.bottleCount ?? 0}
|
||||
/>
|
||||
<Statistic
|
||||
title="物流费合计"
|
||||
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
|
||||
prefix="¥"
|
||||
precision={2}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: {
|
||||
status?: string;
|
||||
providerId?: string;
|
||||
month?: Dayjs;
|
||||
}) => {
|
||||
setFilters({
|
||||
status: v.status || '',
|
||||
providerId: v.providerId || '',
|
||||
year: v.month ? String(v.month.year()) : '',
|
||||
month: v.month ? String(v.month.month() + 1) : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'UNPAID', label: '未结算' },
|
||||
{ value: 'PAID', label: '已结算' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="providerId" label="承运商">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
options={providers.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="month" label="账期">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
|
||||
setGenOpen(true);
|
||||
}}
|
||||
>
|
||||
生成账单
|
||||
</Button>
|
||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||
导出
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedKeys.length}
|
||||
onClick={() =>
|
||||
confirmPay(
|
||||
selectedKeys.map(String),
|
||||
selectedAmount,
|
||||
)
|
||||
}
|
||||
>
|
||||
批量结算
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={billColumns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="物流对账明细"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="承运商">
|
||||
{detail.providerName} ({detail.providerCode})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账期">
|
||||
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="物流费">
|
||||
¥{Number(detail.logisticsAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{STATUS_LABELS[detail.status] || detail.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算时间">
|
||||
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={detail.items ?? []}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
|
||||
{
|
||||
title: '物流费',
|
||||
dataIndex: 'logisticsAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '发货时间',
|
||||
dataIndex: 'shippedAt',
|
||||
width: 160,
|
||||
render: fmtTime,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="生成物流月账单"
|
||||
open={genOpen}
|
||||
onCancel={() => setGenOpen(false)}
|
||||
onOk={() => void generateBills()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={genForm} layout="vertical">
|
||||
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
|
||||
<DatePicker picker="month" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="providerId" label="承运商(空=全部)">
|
||||
<Select
|
||||
allowClear
|
||||
options={providers.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
|
||||
open={rechargeOpen}
|
||||
onCancel={() => setRechargeOpen(false)}
|
||||
onOk={() => void submitRecharge()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={rechargeForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="金额"
|
||||
rules={[{ required: true, message: '请输入充值金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input placeholder="可选,如预充运费" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,461 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Checkbox, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type ParentAccount = { id: string; name: string; phone: string };
|
||||
|
||||
type AccountTreeRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
status: string;
|
||||
isPrimary: number;
|
||||
staffRole?: string | null;
|
||||
permissions?: string[] | null;
|
||||
parentAccountId?: string | null;
|
||||
parent?: ParentAccount | null;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
companyName?: string | null;
|
||||
children?: AccountTreeRow[];
|
||||
};
|
||||
|
||||
type BillRow = {
|
||||
id: string; billNo: string; totalAmount: number; orderCommission: number; redeemCommission: number;
|
||||
status: string; periodStart: string; periodEnd: string; createdAt: string;
|
||||
};
|
||||
|
||||
type OrderRow = {
|
||||
id: string; orderNo: string; status: string; payAmount: number; createdAt: string;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
};
|
||||
|
||||
type Detail = AccountTreeRow & { bills?: BillRow[]; orders?: OrderRow[] };
|
||||
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
const PERMISSION_OPTIONS = PARTNER_PERMISSION_KEYS.map((key: PartnerPermissionKey) => ({
|
||||
value: key,
|
||||
label: PARTNER_PERMISSION_LABELS[key],
|
||||
}));
|
||||
|
||||
function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
|
||||
const phoneQ = phone?.trim();
|
||||
const match = (row: AccountTreeRow) => {
|
||||
const phoneOk = !phoneQ || row.phone.includes(phoneQ);
|
||||
const statusOk = !status || row.status === status;
|
||||
return phoneOk && statusOk;
|
||||
};
|
||||
|
||||
const walk = (list: AccountTreeRow[]): AccountTreeRow[] => {
|
||||
const result: AccountTreeRow[] = [];
|
||||
for (const row of list) {
|
||||
const children = row.children?.length ? walk(row.children) : undefined;
|
||||
if (match(row) || (children && children.length > 0)) {
|
||||
result.push({ ...row, children: children?.length ? children : undefined });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return walk(rows);
|
||||
}
|
||||
|
||||
function staffRoleLabel(role?: string | null) {
|
||||
if (!role) return '-';
|
||||
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role;
|
||||
}
|
||||
|
||||
export default function PartnerAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
|
||||
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
const path = qs.toString() ? `/admin/partner-accounts/tree?${qs}` : '/admin/partner-accounts/tree';
|
||||
const res = await request<AccountTreeRow[]>(path);
|
||||
setTreeData(filterTree(res, filters.phone, filters.status));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTree();
|
||||
}, [loadTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen || !detail) return;
|
||||
editForm.setFieldsValue({
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
permissions: detail.permissions ?? [],
|
||||
});
|
||||
}, [drawerOpen, detail, editForm]);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setPartners(res.items);
|
||||
}
|
||||
|
||||
async function openAccount(id: string) {
|
||||
try {
|
||||
const d = await request<Detail>(`/admin/partner-accounts/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载账户详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadTree();
|
||||
}
|
||||
|
||||
async function deleteSubAccount(row: AccountTreeRow) {
|
||||
await request(`/admin/partner-accounts/${row.id}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
void loadTree();
|
||||
}
|
||||
|
||||
function openAddSub(parent: AccountTreeRow) {
|
||||
setSubParent(parent);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
function collectExpandableKeys(rows: AccountTreeRow[]): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.children?.length) {
|
||||
keys.push(row.id);
|
||||
keys.push(...collectExpandableKeys(row.children));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AccountTreeRow> = [
|
||||
{
|
||||
title: '姓名 / 类型',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (_, row) => (
|
||||
<AdminCellLine
|
||||
primary={row.name}
|
||||
secondary={
|
||||
row.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
||||
: '主账号'
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '开城合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.companyName || row.parent?.name || '—',
|
||||
},
|
||||
{
|
||||
title: '账号类型',
|
||||
width: 90,
|
||||
render: (_, row) => (
|
||||
<Tag color={row.parentAccountId ? 'default' : 'blue'}>
|
||||
{row.parentAccountId ? '子账号' : '主账号'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 100,
|
||||
render: (role) => staffRoleLabel(role),
|
||||
},
|
||||
{
|
||||
title: '所属主账号',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
row.parentAccountId && row.parent
|
||||
? `${row.parent.name} / ${row.parent.phone}`
|
||||
: '-'
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>详情</Button>
|
||||
{!row.parentAccountId ? (
|
||||
<Button type="link" size="small" onClick={() => openAddSub(row)}>添加子账号</Button>
|
||||
) : null}
|
||||
{row.parentAccountId ? (
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => void deleteSubAccount(row)}>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const billColumns: ColumnsType<BillRow> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 140 },
|
||||
{ title: '总额', dataIndex: 'totalAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '订单佣金', dataIndex: 'orderCommission', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{PARTNER_BILL_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart)} ~ ${fmtTime(r.periodEnd)}` },
|
||||
];
|
||||
|
||||
const orderColumns: ColumnsType<OrderRow> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
||||
{ title: '金额', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters({
|
||||
phone: v.phone?.trim() || undefined,
|
||||
status: v.status || undefined,
|
||||
partnerId: v.partnerId || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 180 }}
|
||||
placeholder="全部"
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
onFocus={() => void loadPartners()}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => setExpandedRowKeys(collectExpandableKeys(treeData))}>全部展开</Button>
|
||||
<Button onClick={() => setExpandedRowKeys([])}>全部收起</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table<AccountTreeRow>
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={treeData}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as string[]),
|
||||
defaultExpandAllRows: false,
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title={detail?.parentAccountId ? '子账号详情' : '主账号详情(只读)'}
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
extra={
|
||||
detail?.parentAccountId
|
||||
? <Button type="primary" onClick={() => void saveAccount()}>保存</Button>
|
||||
: null
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
key={detail.id}
|
||||
initialValues={{
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
permissions: detail.permissions ?? [],
|
||||
}}
|
||||
>
|
||||
<Form.Item label="开城合伙人">
|
||||
<Input value={detail.companyName || detail.parent?.name || '—'} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="账号类型">
|
||||
<Input
|
||||
value={
|
||||
detail.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
|
||||
: '主账号'
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
{detail.parentAccountId && detail.parent ? (
|
||||
<Form.Item label="所属主账号">
|
||||
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input disabled={!detail.parentAccountId} /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} disabled={!detail.parentAccountId} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select disabled={!detail.parentAccountId} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
{detail.parentAccountId ? (
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERMISSION_OPTIONS} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{!detail.parentAccountId ? (
|
||||
<Typography.Text type="secondary">
|
||||
主账号请在「开城合伙人」页面编辑。
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
合伙人 H5 登录使用「登录手机」,与主账号联系电话可不同。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bills',
|
||||
label: `账单 (${detail.bills?.length ?? 0})`,
|
||||
children: (
|
||||
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
|
||||
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
label: `名下订单 (${detail.orders?.length ?? 0})`,
|
||||
children: (
|
||||
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
|
||||
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} />
|
||||
),
|
||||
},
|
||||
]} />
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal
|
||||
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!subParent) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
parentAccountId: subParent.id,
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
staffRole: v.staffRole,
|
||||
permissions: v.permissions,
|
||||
}),
|
||||
});
|
||||
message.success('子账号已创建');
|
||||
setSubOpen(false);
|
||||
setExpandedRowKeys((keys) => [...new Set([...keys, subParent.id])]);
|
||||
void loadTree();
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERMISSION_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
billNo: string;
|
||||
orderCommission: number;
|
||||
redeemCommission: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
rejectReason?: string | null;
|
||||
partner?: { companyName?: string; phone?: string };
|
||||
partnerAccount?: { companyName?: string; phone?: string };
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_REVIEW: '待审核',
|
||||
AWAITING_CONFIRM: '待合伙人确认',
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING_REVIEW: 'gold',
|
||||
AWAITING_CONFIRM: 'blue',
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
export default function PartnerBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partner-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
const [genForm] = Form.useForm();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
|
||||
const [rejectForm] = Form.useForm();
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setPartners(res.items))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
|
||||
const year = values.month.year();
|
||||
const month = values.month.month() + 1;
|
||||
if (!values.partnerId) {
|
||||
const result = await request<{ success: number; failed: number; total: number }>(
|
||||
'/admin/partner-bills/generate-all',
|
||||
{ method: 'POST', body: JSON.stringify({ year, month }) },
|
||||
);
|
||||
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
|
||||
} else {
|
||||
await request('/admin/partner-bills/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
|
||||
});
|
||||
message.success('账单已生成(待审核)');
|
||||
}
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
}
|
||||
|
||||
function sendBills(ids: string[]) {
|
||||
Modal.confirm({
|
||||
title: '发送给合伙人?',
|
||||
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
|
||||
okText: '确认发送',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/partner-bills/batch-send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已发送');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function markPaid(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
});
|
||||
} else {
|
||||
await request('/admin/partner-bills/batch-mark-paid', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已标记打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openReject(row: Row) {
|
||||
Modal.confirm({
|
||||
title: '驳回该账单?',
|
||||
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
|
||||
okText: '继续填写理由',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
setRejectTarget(row);
|
||||
rejectForm.resetFields();
|
||||
setRejectOpen(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function submitReject(values: { reason: string }) {
|
||||
if (!rejectTarget) return;
|
||||
setRejecting(true);
|
||||
try {
|
||||
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: values.reason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setRejectOpen(false);
|
||||
setRejectTarget(null);
|
||||
reload();
|
||||
} finally {
|
||||
setRejecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
|
||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
||||
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');
|
||||
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_, r) => partnerName(r),
|
||||
},
|
||||
{
|
||||
title: '账期',
|
||||
width: 200,
|
||||
render: (_, r) =>
|
||||
`${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`,
|
||||
},
|
||||
{
|
||||
title: '酒单佣金',
|
||||
dataIndex: 'orderCommission',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '核销佣金',
|
||||
dataIndex: 'redeemCommission',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '合计应付',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 130,
|
||||
render: (s, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
|
||||
{s === 'REJECTED' && row.rejectReason ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.rejectReason}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap>
|
||||
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
|
||||
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
|
||||
发送
|
||||
</Button>
|
||||
)}
|
||||
{row.status === 'UNPAID' && (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{row.status === 'AWAITING_CONFIRM' && (
|
||||
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||
驳回
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
合伙人账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="酒单佣金合计" value={summary.orderCommission ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="核销佣金合计" value={summary.redeemCommission ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付合计" value={summary.totalAmount} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: { partnerId?: string; status?: string; month?: Dayjs }) => {
|
||||
setFilters({
|
||||
partnerId: v.partnerId || '',
|
||||
status: v.status || '',
|
||||
year: v.month ? String(v.month.year()) : '',
|
||||
month: v.month ? String(v.month.month() + 1) : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="partnerId" label="合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="全部合伙人"
|
||||
style={{ width: 220 }}
|
||||
optionFilterProp="label"
|
||||
options={partners.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.companyName || p.phone || p.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={() => setGenOpen(true)}>
|
||||
生成账单
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
|
||||
批量发送 ({canSend.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canPay.length}
|
||||
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
|
||||
>
|
||||
批量打款 ({canPay.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
}}
|
||||
scroll={{ x: 1280 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Form
|
||||
form={genForm}
|
||||
layout="vertical"
|
||||
initialValues={{ month: dayjs().subtract(1, 'month') }}
|
||||
onFinish={generateBill}
|
||||
>
|
||||
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}>
|
||||
<DatePicker picker="month" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="全部主合伙人"
|
||||
options={partners.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.companyName || p.phone || p.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
生成(待审核)
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="驳回打款申请"
|
||||
open={rejectOpen}
|
||||
onCancel={() => {
|
||||
setRejectOpen(false);
|
||||
setRejectTarget(null);
|
||||
}}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
账单 {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
|
||||
</Typography.Paragraph>
|
||||
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
|
||||
<Form.Item
|
||||
name="reason"
|
||||
label="驳回理由"
|
||||
rules={[
|
||||
{ required: true, message: '请填写驳回理由' },
|
||||
{ max: 500, message: '不超过 500 字' },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
|
||||
确认驳回
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
type PartnerStaffRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
partnerAccountId: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
companyName: string | null;
|
||||
isSubAccount?: boolean;
|
||||
staffRole?: string | null;
|
||||
category: PartnerLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||
if (!json) return '—';
|
||||
const text = JSON.stringify(json);
|
||||
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
||||
}
|
||||
|
||||
export default function PartnerLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
partnerId: searchParams.get('partnerId') ?? '',
|
||||
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
|
||||
phone: searchParams.get('phone') ?? '',
|
||||
companyName: searchParams.get('companyName') ?? '',
|
||||
eventName: searchParams.get('eventName') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/logs/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.eventName) qs.set('eventName', filters.eventName);
|
||||
if (category) qs.set('category', category);
|
||||
return qs;
|
||||
},
|
||||
[filters, category],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
r.isSubAccount ? (
|
||||
<Tag color="blue">子账号</Tag>
|
||||
) : (
|
||||
<AdminCellLine primary={r.companyName} secondary={r.partnerId} />
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.accountName}
|
||||
secondary={
|
||||
r.isSubAccount && r.staffRole
|
||||
? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}`
|
||||
: r.accountPhone || r.partnerAccountId
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 100,
|
||||
render: (v: PartnerLogCategory | null, r) => (
|
||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
||||
{
|
||||
title: '关联',
|
||||
width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/logs/partners/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
合伙人日志
|
||||
</Typography.Title>
|
||||
<Segmented
|
||||
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory(String(v));
|
||||
setPage(1);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (v) next.set('category', String(v));
|
||||
else next.delete('category');
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
|
||||
onFinish={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID">
|
||||
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="eventName" label="事件名">
|
||||
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({
|
||||
partnerId: '',
|
||||
partnerAccountId: '',
|
||||
phone: '',
|
||||
companyName: '',
|
||||
eventName: '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{Object.entries(detail).map(([k, v]) => (
|
||||
<Descriptions.Item key={k} label={k}>
|
||||
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CityPartnerScopeType,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
contactPhone?: string | null;
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
storeCount: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type WarehouseOption = { id: string; name: string };
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
|
||||
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
|
||||
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
|
||||
|
||||
function formatApiError(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||
if (!(err instanceof Error)) return '操作失败';
|
||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||
const label = districtCodeLabel(code);
|
||||
return label !== code ? `${label}(${code})` : code;
|
||||
});
|
||||
}
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
const loadWarehouses = useCallback(async (cityId: string) => {
|
||||
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
|
||||
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
if (d.cityId) await loadWarehouses(d.cityId);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus,
|
||||
managedWarehouseId: d.managedWarehouseId,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
};
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||
新建城市合伙人
|
||||
</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></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 ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={editCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: '子账号',
|
||||
children: (
|
||||
<>
|
||||
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
|
||||
添加子账号
|
||||
</Button>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.children ?? []}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '手机', dataIndex: 'phone' },
|
||||
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="新建城市合伙人"
|
||||
open={createOpen}
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => {
|
||||
setCreateCityId(id);
|
||||
createForm.setFieldsValue({ districtCodes: undefined });
|
||||
void loadWarehouses(id);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
{createCityId && (
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="添加子账号"
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Select mode="multiple" options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
REDEEM_PENDING_STATUS_LABELS,
|
||||
type RedeemPendingItem,
|
||||
type RedeemPendingStatus,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||
PENDING: 'orange',
|
||||
COMPLETED: 'green',
|
||||
REJECTED: 'default',
|
||||
};
|
||||
|
||||
export default function PendingRedeemPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
|
||||
'/admin/redeem-pending',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
|
||||
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
|
||||
message.success('已补核销');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '补核销失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!detail || !rejectReason.trim()) {
|
||||
message.error('请填写驳回原因');
|
||||
return;
|
||||
}
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/redeem-pending/${detail.id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: rejectReason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setRejectOpen(false);
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '驳回失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RedeemPendingItem> = [
|
||||
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
|
||||
{
|
||||
title: '核销码 ID',
|
||||
dataIndex: 'redeemToken',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}…</Typography.Text>,
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
|
||||
{
|
||||
title: '用户',
|
||||
width: 120,
|
||||
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: RedeemPendingStatus) => (
|
||||
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>待处理核销(弱网兜底)</Typography.Title>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="pendingNo" label="待处理单号">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemToken" label="核销码 ID">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店 ID">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="待处理核销详情"
|
||||
width={560}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' && (
|
||||
<Space>
|
||||
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
|
||||
驳回
|
||||
</Button>
|
||||
<Button type="primary" loading={acting} onClick={() => void complete()}>
|
||||
人工补核销
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{detail.photoUrl && (
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
|
||||
</div>
|
||||
)}
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销码 ID">
|
||||
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status]}>
|
||||
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
|
||||
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{detail.store?.name || '—'}({detail.store?.id})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联核销单">
|
||||
{detail.redeemRecord?.redeemNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="处理时间">
|
||||
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="驳回待处理单"
|
||||
open={rejectOpen}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
|
||||
onOk={() => void reject()}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请填写驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space,
|
||||
Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
AROMA_TYPE_LABELS, DETAIL_TEMPLATE_STATUS_LABELS, fmtTime,
|
||||
} from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
||||
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
||||
|
||||
type Row = ProductDetailTemplateDto;
|
||||
|
||||
type TemplateFormValues = {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
aromaType?: string | null;
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||
detailImageUrls?: string[];
|
||||
suggestedDetailImageCount?: number;
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
function mapToForm(d: Record<string, unknown>) {
|
||||
const row = d as Row;
|
||||
return {
|
||||
...row,
|
||||
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
|
||||
features: row.features?.length
|
||||
? row.features
|
||||
: [{ icon: 'star', title: '', desc: '' }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(v: TemplateFormValues) {
|
||||
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
|
||||
const features = (v.features ?? [])
|
||||
.filter((f) => f?.title?.trim() || f?.desc?.trim())
|
||||
.map((f) => ({
|
||||
icon: f.icon?.trim() || 'star',
|
||||
title: f.title?.trim() ?? '',
|
||||
desc: f.desc?.trim() ?? '',
|
||||
}));
|
||||
return {
|
||||
code: v.code.trim(),
|
||||
name: v.name.trim(),
|
||||
description: v.description?.trim() || undefined,
|
||||
aromaType: v.aromaType || null,
|
||||
storyTitle: v.storyTitle?.trim() || undefined,
|
||||
storyText: v.storyText?.trim() || undefined,
|
||||
features,
|
||||
detailImageUrls,
|
||||
suggestedDetailImageCount: detailImageUrls.length || (v.suggestedDetailImageCount ?? 1),
|
||||
sortOrder: v.sortOrder ?? 0,
|
||||
status: v.status ?? 'ACTIVE',
|
||||
};
|
||||
}
|
||||
|
||||
function TemplateContentFields() {
|
||||
return (
|
||||
<>
|
||||
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改)</Typography.Text>
|
||||
<DetailImageUrlList
|
||||
label="详情图"
|
||||
bizType="DETAIL_TEMPLATE"
|
||||
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
||||
/>
|
||||
<Divider />
|
||||
<Form.Item name="storyTitle" label="故事标题">
|
||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||
</Form.Item>
|
||||
<Form.Item name="storyText" label="故事正文">
|
||||
<Input.TextArea rows={4} placeholder="商品故事描述" />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">卖点特色</Typography.Text>
|
||||
<Form.List name="features">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
|
||||
<Space align="start">
|
||||
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="material icon 名" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="标题" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
|
||||
)}
|
||||
</Space>
|
||||
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="简短描述" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
|
||||
添加卖点
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductDetailTemplatesPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/product-detail-templates',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.code) qs.set('code', filters.code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.aromaType) qs.set('aromaType', filters.aromaType);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 120 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '说明', dataIndex: 'description', width: 200, ellipsis: true },
|
||||
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
|
||||
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{DETAIL_TEMPLATE_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(mapToForm(d));
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>商品详情模板</Typography.Title>
|
||||
<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="code" label="编码"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="香型">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</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: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
const payload = buildPayload(v);
|
||||
await request(`/admin/product-detail-templates/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="推荐香型">
|
||||
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<TemplateContentFields />
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建详情模板" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
const payload = buildPayload(v);
|
||||
await request('/admin/product-detail-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}} width={640}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{
|
||||
status: 'ACTIVE', sortOrder: 0,
|
||||
detailImageUrls: [''],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="推荐香型">
|
||||
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<TemplateContentFields />
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,566 +0,0 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||
Switch, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
|
||||
type ProductDetailContentDto = {
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
skuCode: string;
|
||||
barcode69: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
aromaType: string;
|
||||
spec: string;
|
||||
price: number;
|
||||
benefitAmount: number;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
allowOnSitePickup?: boolean;
|
||||
allowOnlinePurchase?: boolean;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
detailContent?: ProductDetailContentDto | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProductFormValues = {
|
||||
skuCode?: string;
|
||||
barcode69?: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
aromaType?: string;
|
||||
spec: string;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
allowOnSitePickup?: boolean;
|
||||
allowOnlinePurchase?: boolean;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
coverUrl?: string;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||
};
|
||||
|
||||
type UserPickRow = {
|
||||
id: string;
|
||||
phone?: string | null;
|
||||
nickname?: string | null;
|
||||
userNo?: string;
|
||||
};
|
||||
|
||||
function mapDetailToForm(d: Record<string, unknown>) {
|
||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||
const row = d as Row;
|
||||
return {
|
||||
...d,
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
|
||||
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
|
||||
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
||||
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
||||
allowOnSitePickup: !!row.allowOnSitePickup,
|
||||
visibilityWhitelistEnabled: !!row.visibilityWhitelistEnabled,
|
||||
visibilityPhones: (row.visibilityPhones ?? []) as string[],
|
||||
storyTitle: detail.storyTitle ?? '',
|
||||
storyText: detail.storyText ?? '',
|
||||
features: detail.features?.length
|
||||
? detail.features
|
||||
: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildProductPayload(v: ProductFormValues) {
|
||||
const carouselUrls = (v.carouselUrls ?? []).map((u) => u?.trim()).filter(Boolean);
|
||||
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
|
||||
const features = (v.features ?? [])
|
||||
.filter((f) => f?.title?.trim() || f?.desc?.trim())
|
||||
.map((f) => ({
|
||||
icon: f.icon?.trim() || 'star',
|
||||
title: f.title?.trim() || '',
|
||||
desc: f.desc?.trim() || '',
|
||||
}));
|
||||
|
||||
const detailContent: ProductDetailContentDto = {
|
||||
storyTitle: v.storyTitle?.trim() || undefined,
|
||||
storyText: v.storyText?.trim() || undefined,
|
||||
features: features.length ? features : undefined,
|
||||
};
|
||||
|
||||
const visibilityPhones = (v.visibilityPhones ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
barcode69: v.barcode69,
|
||||
name: v.name,
|
||||
subtitle: v.subtitle,
|
||||
aromaType: v.aromaType,
|
||||
spec: v.spec,
|
||||
price: v.price,
|
||||
benefitAmount: v.benefitAmount,
|
||||
status: v.status,
|
||||
sortOrder: v.sortOrder,
|
||||
allowOnSitePickup: !!v.allowOnSitePickup,
|
||||
allowOnlinePurchase: v.allowOnlinePurchase !== false,
|
||||
allowCrossCityDelivery:
|
||||
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
detailContent,
|
||||
};
|
||||
}
|
||||
|
||||
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
||||
return (
|
||||
<Form.List name={name}>
|
||||
{(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={bizType} mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
||||
添加{label}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaType?: string }) {
|
||||
return (
|
||||
<>
|
||||
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
||||
<Divider />
|
||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL)</Typography.Text>
|
||||
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
||||
<Divider />
|
||||
<Typography.Text type="secondary">详情长图(DETAIL,可逐张修改)</Typography.Text>
|
||||
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
||||
<Divider />
|
||||
<Form.Item name="storyTitle" label="故事标题">
|
||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||
</Form.Item>
|
||||
<Form.Item name="storyText" label="故事正文">
|
||||
<Input.TextArea rows={4} placeholder="商品故事描述" />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">卖点特色</Typography.Text>
|
||||
<Form.List name="features">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
|
||||
<Space align="start">
|
||||
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="material icon 名" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="标题" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
|
||||
)}
|
||||
</Space>
|
||||
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="简短描述" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
|
||||
添加卖点
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
||||
const [userSearching, setUserSearching] = useState(false);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
const q = keyword.trim();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
if (!q) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
setUserSearching(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
||||
} catch {
|
||||
setUserOptions([]);
|
||||
} finally {
|
||||
setUserSearching(false);
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
{enabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityPhones"
|
||||
label="白名单手机号"
|
||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
||||
placeholder="输入手机号后回车"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="从用户库添加">
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
placeholder="按手机号搜索用户"
|
||||
loading={userSearching}
|
||||
options={userOptions.map((u) => ({
|
||||
value: u.phone!,
|
||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
||||
}))}
|
||||
onSearch={searchUsers}
|
||||
onSelect={(phone: string) => {
|
||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
||||
if (!cur.includes(phone)) {
|
||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
||||
}
|
||||
}}
|
||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
|
||||
return (
|
||||
<>
|
||||
{mode === 'create' && (
|
||||
<>
|
||||
<Form.Item label="SKU">
|
||||
<Input disabled placeholder="保存后自动生成" />
|
||||
</Form.Item>
|
||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}>
|
||||
<Input placeholder="500ml | 52度" />
|
||||
</Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<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}>
|
||||
{() => (
|
||||
<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" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/products',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.aromaType) qs.set('aromaType', filters.aromaType);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
async function handleDelete(row: Row) {
|
||||
try {
|
||||
await request(`/admin/products/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
if (detail?.id === row.id) {
|
||||
setDrawerOpen(false);
|
||||
setDetail(null);
|
||||
}
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
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: 'status', width: 80, render: (s) => (
|
||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||
) },
|
||||
{
|
||||
title: '白名单',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</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) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(mapDetailToForm(d));
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该商品?"
|
||||
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleDelete(row)}
|
||||
>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [detail, editForm]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
|
||||
<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="status" label="状态">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="香型">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</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)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||
return;
|
||||
}
|
||||
const payload = buildProductPayload(v);
|
||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
)}>
|
||||
{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="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs items={[
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情页',
|
||||
children: (
|
||||
<ProductDetailFields
|
||||
form={editForm}
|
||||
aromaType={String(detail.aromaType ?? '')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||
return;
|
||||
}
|
||||
const payload = buildProductPayload(v);
|
||||
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}} width={720}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
||||
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
||||
carouselUrls: [''], detailImageUrls: [''],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
<Tabs items={[
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" form={createForm} /> },
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情页',
|
||||
children: (
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.aromaType !== cur.aromaType}>
|
||||
{() => (
|
||||
<ProductDetailFields
|
||||
form={createForm}
|
||||
aromaType={createForm.getFieldValue('aromaType') as string | undefined}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PROMO_CODE_SCENE_LABELS,
|
||||
PROMO_CODE_STATUS_LABELS,
|
||||
promoConversion,
|
||||
type PromoCodeItem,
|
||||
type PromoCodeScene,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = PromoCodeItem;
|
||||
|
||||
type SceneOption = { value: PromoCodeScene; label: string };
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
} catch {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
export default function PromoCodesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filterForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [scenes, setScenes] = useState<SceneOption[]>(
|
||||
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
||||
value: value as PromoCodeScene,
|
||||
label,
|
||||
})),
|
||||
);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/promo-codes',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.code) qs.set('code', filters.code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.scene) qs.set('scene', filters.scene);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
async function loadScenes() {
|
||||
try {
|
||||
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
|
||||
if (list.length) setScenes(list);
|
||||
} catch {
|
||||
/* 使用本地默认场景 */
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadScenes();
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||
{
|
||||
title: '场景',
|
||||
dataIndex: 'scene',
|
||||
width: 110,
|
||||
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
|
||||
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
{
|
||||
title: '转化率',
|
||||
width: 90,
|
||||
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
||||
},
|
||||
{
|
||||
title: '渠道负责人',
|
||||
width: 120,
|
||||
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size="small" wrap>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={!row.qrcodeUrl}
|
||||
onClick={() => {
|
||||
if (!row.qrcodeUrl) return;
|
||||
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
|
||||
}}
|
||||
>
|
||||
下载小程序码
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
||||
关联用户
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
async function handleCreate(values: Record<string, string>) {
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await request<PromoCodeItem>('/admin/promo-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
code: values.code?.trim() || undefined,
|
||||
scene: values.scene,
|
||||
ownerUserId: values.ownerUserId?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
page: values.page?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('推广码已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
navigate(`/promo-codes/${created.id}`);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => { setFilters(v); setPage(1); }}
|
||||
>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="scene" label="场景">
|
||||
<Select allowClear style={{ width: 130 }} options={scenes} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</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: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="创建推广码"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
|
||||
<Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="如:郑州品鉴会、门店地推" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
|
||||
<Select options={scenes} />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="自定义码值(选填)">
|
||||
<Input placeholder="留空自动生成,如 DKDEMO1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="page"
|
||||
label="小程序落地页(选填)"
|
||||
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
|
||||
>
|
||||
<Input placeholder="pages/home/index" />
|
||||
</Form.Item>
|
||||
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
|
||||
<Input placeholder="渠道负责人,填写用户数据库 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={creating} block>
|
||||
创建并生成小程序码
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
|
||||
} from 'antd';
|
||||
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type ApiResult = Record<string, unknown>;
|
||||
|
||||
function ResultBox({ data }: { data: ApiResult | null }) {
|
||||
if (!data) return <Typography.Text type="secondary">调用后在此显示结果</Typography.Text>;
|
||||
return (
|
||||
<pre style={{
|
||||
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||
maxHeight: 320, overflow: 'auto', fontSize: 12,
|
||||
}}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RedeemDebugPage() {
|
||||
const [createForm] = Form.useForm();
|
||||
const [previewForm] = Form.useForm();
|
||||
const [confirmForm] = Form.useForm();
|
||||
const [phoneLookupForm] = Form.useForm();
|
||||
const [phoneBalanceForm] = Form.useForm();
|
||||
const [phonePrepareForm] = Form.useForm();
|
||||
const [phoneConfirmForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createResult, setCreateResult] = useState<ApiResult | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<ApiResult | null>(null);
|
||||
const [confirmResult, setConfirmResult] = useState<ApiResult | null>(null);
|
||||
const [phoneLookupResult, setPhoneLookupResult] = useState<ApiResult | null>(null);
|
||||
const [phoneBalanceResult, setPhoneBalanceResult] = useState<ApiResult | null>(null);
|
||||
const [phonePrepareResult, setPhonePrepareResult] = useState<ApiResult | null>(null);
|
||||
const [phoneConfirmResult, setPhoneConfirmResult] = useState<ApiResult | null>(null);
|
||||
|
||||
async function invoke(
|
||||
path: string,
|
||||
body: unknown,
|
||||
setResult: (v: ApiResult | null) => void,
|
||||
successMsg: string,
|
||||
onSuccess?: (res: ApiResult) => void,
|
||||
) {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await request<ApiResult>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setResult(res);
|
||||
message.success(successMsg);
|
||||
onSuccess?.(res);
|
||||
if (path.includes('create-token') && res.token) {
|
||||
previewForm.setFieldsValue({ token: res.token });
|
||||
confirmForm.setFieldsValue({ token: res.token });
|
||||
}
|
||||
if (path.includes('phone/balance') && res.sessionId) {
|
||||
phonePrepareForm.setFieldsValue({
|
||||
storeId: phoneBalanceForm.getFieldValue('storeId'),
|
||||
sessionId: res.sessionId,
|
||||
});
|
||||
phoneConfirmForm.setFieldsValue({
|
||||
storeId: phoneBalanceForm.getFieldValue('storeId'),
|
||||
sessionId: res.sessionId,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '调用失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenTabItems = [
|
||||
{
|
||||
key: 'create',
|
||||
label: '1. 生成核销码',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="userId"
|
||||
label="用户"
|
||||
rules={[{ required: true, message: '请填写用户 ID、编号或手机号' }]}
|
||||
>
|
||||
<Input placeholder="数据库 ID / 用户编号 / 手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="couponId" label="指定券 ID(可选)">
|
||||
<Input placeholder="不填则自动分摊" />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="绑定门店 ID(可选)">
|
||||
<Input placeholder="绑定后仅该门店可核销" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void createForm.validateFields().then((values) => {
|
||||
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
|
||||
});
|
||||
}}
|
||||
>
|
||||
生成核销码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={createResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
label: '2. 预览核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={previewForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = previewForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
|
||||
}}
|
||||
>
|
||||
预览
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={previewResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'confirm',
|
||||
label: '3. 确认核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={confirmForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = confirmForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
|
||||
}}
|
||||
>
|
||||
确认核销
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={confirmResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const phoneTabItems = [
|
||||
{
|
||||
key: 'phone-lookup',
|
||||
label: '1. 发送查权益验证码',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={phoneLookupForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
|
||||
<Input placeholder="11 位手机号" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void phoneLookupForm.validateFields().then((values) => {
|
||||
phoneBalanceForm.setFieldsValue({
|
||||
storeId: values.storeId,
|
||||
phone: values.phone,
|
||||
});
|
||||
void invoke(
|
||||
'/admin/redeem/debug/phone/send-lookup-sms',
|
||||
values,
|
||||
setPhoneLookupResult,
|
||||
'查权益验证码已发送',
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={phoneLookupResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone-balance',
|
||||
label: '2. 验证并查权益',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={phoneBalanceForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="验证码" rules={[{ required: true }]}>
|
||||
<Input placeholder={`Mock 默认 ${MOCK_SMS_FIXED_CODE}`} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void phoneBalanceForm.validateFields().then((values) => {
|
||||
void invoke('/admin/redeem/debug/phone/balance', values, setPhoneBalanceResult, '权益查询成功');
|
||||
});
|
||||
}}
|
||||
>
|
||||
查询权益
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={phoneBalanceResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone-prepare',
|
||||
label: '3. 发送核销确认码',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={phonePrepareForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="上一步返回的 sessionId" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void phonePrepareForm.validateFields().then((values) => {
|
||||
phoneConfirmForm.setFieldsValue({
|
||||
storeId: values.storeId,
|
||||
sessionId: values.sessionId,
|
||||
});
|
||||
void invoke('/admin/redeem/debug/phone/prepare', values, setPhonePrepareResult, '核销确认码已发送');
|
||||
});
|
||||
}}
|
||||
>
|
||||
发送确认码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={phonePrepareResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone-confirm',
|
||||
label: '4. 确认核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={phoneConfirmForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="核销确认验证码" rules={[{ required: true }]}>
|
||||
<Input placeholder={`Mock 默认 ${MOCK_SMS_FIXED_CODE}`} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void phoneConfirmForm.validateFields().then((values) => {
|
||||
void invoke('/admin/redeem/debug/phone/confirm', values, setPhoneConfirmResult, '手机号核销成功');
|
||||
});
|
||||
}}
|
||||
>
|
||||
确认核销
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={phoneConfirmResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>核销调试</Typography.Title>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="token"
|
||||
items={[
|
||||
{
|
||||
key: 'token',
|
||||
label: '扫码核销',
|
||||
children: <Tabs items={tokenTabItems} />,
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
label: '手机号核销',
|
||||
children: <Tabs items={phoneTabItems} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, 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';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
store?: { name: string; cityName: string };
|
||||
coupon?: { couponNo: string };
|
||||
};
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/redeem-records',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.channel) qs.set('channel', filters.channel);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '方式',
|
||||
dataIndex: 'channel',
|
||||
width: 110,
|
||||
render: (v: RedeemChannel | undefined) => {
|
||||
const channel = v === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||
return (
|
||||
<Tag color={channel === 'PHONE' ? 'purple' : 'blue'}>
|
||||
{REDEEM_CHANNEL_LABELS[channel]}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="redeemNo" label="核销号">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店ID">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="channel" label="方式">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'SCAN', label: '扫码核销' },
|
||||
{ value: 'PHONE', label: '手机号核销' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="方式">
|
||||
{
|
||||
REDEEM_CHANNEL_LABELS[
|
||||
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel
|
||||
]
|
||||
}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import {
|
||||
MEDIA_TYPE_LABELS,
|
||||
RESOURCE_BIZ_TYPE_LABELS,
|
||||
RESOURCE_OWNER_TYPE_LABELS,
|
||||
RESOURCE_STATUS_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
ownerType: string;
|
||||
ownerId: string;
|
||||
bizType: string;
|
||||
mediaType: string;
|
||||
ossBucket: string;
|
||||
ossKey: string;
|
||||
url: string;
|
||||
fileName?: string | null;
|
||||
sortOrder: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function ResourcesPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [data, setData] = useState<Paginated<Row> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function reload(p = page, ps = pageSize, f = filters) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: String(p), pageSize: String(ps) });
|
||||
if (f.ownerType) qs.set('ownerType', f.ownerType);
|
||||
if (f.ownerId) qs.set('ownerId', f.ownerId);
|
||||
if (f.bizType) qs.set('bizType', f.bizType);
|
||||
if (f.status) qs.set('status', f.status);
|
||||
const res = await request<Paginated<Row>>(`/common/resources?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{
|
||||
title: '归属',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<span>
|
||||
{RESOURCE_OWNER_TYPE_LABELS[row.ownerType] || row.ownerType} / {row.ownerId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '用途', dataIndex: 'bizType', width: 90, render: (v) => RESOURCE_BIZ_TYPE_LABELS[v] || v },
|
||||
{ title: '类型', dataIndex: 'mediaType', width: 70, render: (v) => MEDIA_TYPE_LABELS[v] || v },
|
||||
{
|
||||
title: '预览',
|
||||
dataIndex: 'url',
|
||||
width: 90,
|
||||
render: (url, row) =>
|
||||
row.mediaType === 'IMAGE' ? (
|
||||
<Image src={url} width={56} height={40} style={{ objectFit: 'cover' }} />
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
查看
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{RESOURCE_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title="确认删除该资源?"
|
||||
onConfirm={async () => {
|
||||
await request(`/common/resources/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
OSS 资源库
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
登记资源
|
||||
</Button>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
void reload(1, pageSize, v);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="ownerType" label="归属类型">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="ownerId" label="归属 ID">
|
||||
<Input allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bizType" label="用途">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(RESOURCE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</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: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
void reload(p, ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="登记 OSS 资源"
|
||||
open={createOpen}
|
||||
confirmLoading={saving}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await request('/common/resources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ownerType: v.ownerType,
|
||||
ownerId: v.ownerId,
|
||||
bizType: v.bizType,
|
||||
mediaType: v.mediaType,
|
||||
ossKey: v.ossKey,
|
||||
url: v.url,
|
||||
fileName: v.fileName,
|
||||
sortOrder: v.sortOrder ?? 0,
|
||||
}),
|
||||
});
|
||||
message.success('已登记');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
width={520}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ mediaType: 'IMAGE', bizType: 'COVER', sortOrder: 0 }}>
|
||||
<Form.Item name="ownerType" label="归属类型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="ownerId" label="归属 ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="商品/门店等业务 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bizType" label="用途" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="mediaType" label="媒体类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
...Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label })),
|
||||
{ value: 'FILE', label: '文件' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType || prev.bizType !== cur.bizType}>
|
||||
{({ getFieldValue }) => {
|
||||
const mediaType = getFieldValue('mediaType') || 'IMAGE';
|
||||
const bizType = getFieldValue('bizType') || 'COVER';
|
||||
return (
|
||||
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
|
||||
<OssUpload
|
||||
bizType={bizType}
|
||||
mediaType={mediaType}
|
||||
onUploaded={(result) => {
|
||||
createForm.setFieldsValue({ ossKey: result.ossKey, url: result.url });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name="ossKey" label="OSS Key" rules={[{ required: true }]}>
|
||||
<Input placeholder="上传后自动填入" />
|
||||
</Form.Item>
|
||||
<Form.Item name="fileName" label="文件名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
storeCount?: number;
|
||||
staffCount?: number;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
store?: StoreBrief | null;
|
||||
stores?: StoreBrief[];
|
||||
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string };
|
||||
|
||||
export default function StoreAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
|
||||
|
||||
async function loadStores() {
|
||||
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
async function refreshDetail(accountId: string) {
|
||||
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
|
||||
setDetail(d);
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function deleteStaff(staffId: string) {
|
||||
if (!detail) return;
|
||||
setDeletingStaffId(staffId);
|
||||
try {
|
||||
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
await refreshDetail(detail.id);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setDeletingStaffId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '绑定门店',
|
||||
width: 180,
|
||||
render: (_, row) =>
|
||||
row.stores?.length
|
||||
? row.stores.map((s) => s.name).join('、')
|
||||
: row.store?.name ?? '—',
|
||||
},
|
||||
{
|
||||
title: '门店数',
|
||||
dataIndex: 'storeCount',
|
||||
width: 70,
|
||||
render: (n, row) => n ?? row.stores?.length ?? 0,
|
||||
},
|
||||
{
|
||||
title: '子账号',
|
||||
dataIndex: 'staffCount',
|
||||
width: 70,
|
||||
render: (n) => n ?? 0,
|
||||
},
|
||||
{
|
||||
title: '收款户名',
|
||||
dataIndex: 'bankAccountName',
|
||||
width: 120,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '账号状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void loadStores();
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</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: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title="门店主账号"
|
||||
width={520}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail && (
|
||||
<Select
|
||||
defaultValue={detail.status}
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/store-accounts/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
message.success('已更新');
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款户名">{detail.bankAccountName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款账号">{detail.bankAccountNo || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">{detail.bankBranch || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定门店">
|
||||
{(detail.stores ?? []).map((s) => (
|
||||
<Tag key={s.id}>
|
||||
{s.name}
|
||||
{s.status ? `(${STORE_STATUS_LABELS[s.status] || s.status})` : ''}
|
||||
</Tag>
|
||||
))}
|
||||
{!detail.stores?.length ? '—' : null}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{detail.staff?.length ? (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 24 }}>子账号</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={detail.staff}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '手机', dataIndex: 'phone' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, staff) => (
|
||||
<Popconfirm
|
||||
title="确认删除该子账号?"
|
||||
description={`${staff.name}(${staff.phone})删除后将无法登录门店端`}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
|
||||
cancelText="取消"
|
||||
onConfirm={() => void deleteStaff(staff.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
|
||||
暂无子账号
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal
|
||||
title="新建门店账户"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={stores.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,608 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import {
|
||||
STORE_SETTLEMENT_KIND_LABELS,
|
||||
STORE_SETTLEMENT_STATUS_LABELS,
|
||||
storeSettlementStatusLabel,
|
||||
type StoreSettlementKind,
|
||||
type StoreSettlementStatus,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
kind: StoreSettlementKind;
|
||||
id: string;
|
||||
billNo: string;
|
||||
amount: number;
|
||||
status: StoreSettlementStatus;
|
||||
date: string;
|
||||
overdue?: boolean;
|
||||
redeemCount?: number | null;
|
||||
redeemAmount?: number | null;
|
||||
settlementRate?: number | null;
|
||||
payoutCount?: number | null;
|
||||
store?: { id: string; name: string; cityName: string; phone?: string | null };
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string; phone: string };
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
PENDING_REVIEW: 'orange',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
||||
T1_BILL: 'blue',
|
||||
WITHDRAW: 'purple',
|
||||
};
|
||||
|
||||
export default function StoreBillsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({
|
||||
kind: initialKind,
|
||||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
||||
});
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-settlements',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.kind) qs.set('kind', filters.kind);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [detailKind, setDetailKind] = useState<StoreSettlementKind | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [overdueSummary, setOverdueSummary] = useState<{
|
||||
pendingCount: number;
|
||||
overdueCount: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
kind: filters.kind || undefined,
|
||||
status: filters.status || undefined,
|
||||
});
|
||||
}, [filters.kind, filters.status, form]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setStores(res.items))
|
||||
.catch(() => {});
|
||||
void request<{ pendingCount: number; overdueCount: number }>(
|
||||
'/admin/store-withdrawals/overdue-summary',
|
||||
)
|
||||
.then(setOverdueSummary)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
await request('/admin/store-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
}
|
||||
message.success('已确认打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function approveWithdraw(id: string) {
|
||||
let paymentRef = '';
|
||||
Modal.confirm({
|
||||
title: '审核通过并标记已结算?',
|
||||
content: (
|
||||
<Input
|
||||
placeholder="打款凭证号(可选)"
|
||||
onChange={(e) => {
|
||||
paymentRef = e.target.value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
okText: '通过并结算',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await request(`/admin/store-withdrawals/${id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
|
||||
});
|
||||
message.success('已通过并标记已结算');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
void request<{ pendingCount: number; overdueCount: number }>(
|
||||
'/admin/store-withdrawals/overdue-summary',
|
||||
)
|
||||
.then(setOverdueSummary)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rejectWithdraw(id: string) {
|
||||
let reason = '';
|
||||
Modal.confirm({
|
||||
title: '驳回提现申请?',
|
||||
content: (
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请填写驳回理由"
|
||||
onChange={(e) => {
|
||||
reason = e.target.value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
okText: '确认驳回',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (!reason.trim()) {
|
||||
message.error('请填写驳回理由');
|
||||
throw new Error('reason required');
|
||||
}
|
||||
await request(`/admin/store-withdrawals/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(row: Row) {
|
||||
const path =
|
||||
row.kind === 'WITHDRAW' ? `/admin/store-withdrawals/${row.id}` : `/admin/store-bills/${row.id}`;
|
||||
const d = await request<Record<string, unknown>>(path);
|
||||
setDetail(d);
|
||||
setDetailKind(row.kind);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
||||
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||
message.success(`已导出 ${result.count} 条 T+1 账单`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const selectedRows = (data?.items ?? []).filter(
|
||||
(r) =>
|
||||
selectedKeys.includes(`${r.kind}-${r.id}`) &&
|
||||
r.kind === 'T1_BILL' &&
|
||||
r.status === 'UNPAID',
|
||||
);
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'kind',
|
||||
width: 100,
|
||||
render: (k: StoreSettlementKind) => (
|
||||
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '单号',
|
||||
dataIndex: 'billNo',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (
|
||||
<Space>
|
||||
<span>{v}</span>
|
||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
width: 160,
|
||||
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
||||
},
|
||||
{ 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: 80,
|
||||
render: (_, row) =>
|
||||
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
|
||||
},
|
||||
{
|
||||
title: '应付金额',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string, row) => (
|
||||
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.kind === 'T1_BILL' && row.status === 'UNPAID' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.amount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
)}
|
||||
{row.kind === 'WITHDRAW' && row.status === 'PENDING_REVIEW' && (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={() => approveWithdraw(row.id)}>
|
||||
通过
|
||||
</Button>
|
||||
<Button type="link" size="small" danger onClick={() => rejectWithdraw(row.id)}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const withdrawDetailItems =
|
||||
(detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const storeAccount = detail?.storeAccount as
|
||||
| {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
门店账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口
|
||||
</Typography.Text>
|
||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||
<Typography.Text type="danger">
|
||||
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="记录数" value={summary.count} />
|
||||
<Statistic title="核销金额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付合计" value={summary.payoutAmount ?? summary.totalAmount ?? 0} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
initialValues={{
|
||||
kind: filters.kind || undefined,
|
||||
status: filters.status || undefined,
|
||||
}}
|
||||
onFinish={(v: {
|
||||
kind?: string;
|
||||
storeId?: string;
|
||||
status?: string;
|
||||
range?: [Dayjs, Dayjs];
|
||||
}) => {
|
||||
setFilters({
|
||||
kind: v.kind || '',
|
||||
storeId: v.storeId || '',
|
||||
status: v.status || '',
|
||||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||
});
|
||||
setPage(1);
|
||||
setSelectedKeys([]);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="kind" label="类型">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={Object.entries(STORE_SETTLEMENT_KIND_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="全部门店"
|
||||
style={{ width: 200 }}
|
||||
optionFilterProp="label"
|
||||
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(STORE_SETTLEMENT_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label:
|
||||
value === 'PAID'
|
||||
? '已打款/已结算'
|
||||
: label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="日期">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
setSelectedKeys([]);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||
导出 T+1 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!selectedRows.length}
|
||||
loading={batchLoading}
|
||||
onClick={() => confirmPay(selectedRows.map((r) => r.id), selectedAmount)}
|
||||
>
|
||||
批量确认打款 ({selectedRows.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey={(r) => `${r.kind}-${r.id}`}
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({
|
||||
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
|
||||
}),
|
||||
}}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={detailKind === 'WITHDRAW' ? '手动提现明细' : 'T+1 对账单明细'}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={detailKind === 'WITHDRAW' ? 640 : 520}
|
||||
>
|
||||
{detail && detailKind === 'T1_BILL' && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">
|
||||
{String(detail.billDate || '').slice(0, 10)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="应付">
|
||||
¥{Number(detail.payoutAmount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{storeSettlementStatusLabel('T1_BILL', String(detail.status))}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打款时间">
|
||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
核销明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) =>
|
||||
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'payoutAmount',
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{detail && detailKind === 'WITHDRAW' && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
¥{Number(detail.amount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{storeSettlementStatusLabel('WITHDRAW', String(detail.status))}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="申请时间">
|
||||
{detail.appliedAt ? fmtTime(String(detail.appliedAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="驳回理由">
|
||||
{detail.rejectReason ? String(detail.rejectReason) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打款凭证">
|
||||
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{storeAccount ? (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
收款账户
|
||||
</Typography.Title>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="户名">
|
||||
{storeAccount.bankAccountName || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账号">
|
||||
{storeAccount.bankAccountNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">
|
||||
{storeAccount.bankBranch || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
) : null}
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
提现明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={withdrawDetailItems}
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) => {
|
||||
const payout = r.storePayout as
|
||||
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
|
||||
| undefined;
|
||||
return String(payout?.redeemRecord?.redeemNo || '—');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '应付',
|
||||
render: (_, r) => {
|
||||
const payout = r.storePayout as { payoutAmount?: number } | undefined;
|
||||
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{String(detail.status) === 'PENDING_REVIEW' ? (
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={() => approveWithdraw(String(detail.id))}>
|
||||
通过
|
||||
</Button>
|
||||
<Button danger onClick={() => rejectWithdraw(String(detail.id))}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user