Dev #9
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
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`)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
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 小程序规格 |
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
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 表时)。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 后端速查(杜康好客 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 自动推进状态 |
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 前端速查(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 |
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
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/` 原型混为一谈
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: dukang-release
|
||||||
|
description: >-
|
||||||
|
杜康好客生产发布流程:dev_jacy 提交合并到 dev,并用 deploy.sh 发版。
|
||||||
|
Use when the user says 发布、上传、deploy、上线、发版, or @杜康发布 / @dukang-release.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 杜康发布
|
||||||
|
|
||||||
|
将已完成改动发到生产(`aliyun-smarthr`)。**仅用户明确要求发布时执行。**
|
||||||
|
|
||||||
|
## 分支约定
|
||||||
|
|
||||||
|
| 分支 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `dev_jacy` | 日常开发 / 提交 |
|
||||||
|
| `dev` | **发布分支**(服务器拉这个) |
|
||||||
|
|
||||||
|
默认:`dev_jacy` → merge → `dev` → 部署。勿 force push `main`/`dev`。
|
||||||
|
|
||||||
|
## 标准流程(Windows)
|
||||||
|
|
||||||
|
PowerShell 下 **分支名必须加引号**(否则 `checkout` 与 `dev` 会被粘成 `checkoutdev`)。
|
||||||
|
|
||||||
|
### 1. 提交(在 `dev_jacy`)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status -sb
|
||||||
|
git add <files>
|
||||||
|
git commit -m @"
|
||||||
|
fix(scope): summary
|
||||||
|
|
||||||
|
Optional body.
|
||||||
|
"@
|
||||||
|
```
|
||||||
|
|
||||||
|
- Conventional Commits;不提交 `.env` / `dist` / `node_modules`
|
||||||
|
- 用户未要求 commit 时不要擅自提交
|
||||||
|
|
||||||
|
### 2. 推送并合并到 `dev`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 远程发版
|
||||||
|
|
||||||
|
本机无 WSL bash 时用 **Git Bash**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy.sh" --branch "dev" -- --skip-db
|
||||||
|
```
|
||||||
|
|
||||||
|
- 默认加 `--skip-db`(无迁移时)
|
||||||
|
- 有 Prisma 迁移时去掉 `--skip-db`,按用户确认是否 `--seed` / `--accept-data-loss`
|
||||||
|
- `deploy/deploy.env` 提供 `DEPLOY_HOST` 等(勿提交密钥)
|
||||||
|
|
||||||
|
### 4. 等待完成
|
||||||
|
|
||||||
|
后台跑部署时轮询终端输出,成功标志:
|
||||||
|
|
||||||
|
- 出现 `发版完成`
|
||||||
|
- `exit_code: 0`
|
||||||
|
- `mini-user(h5): 200`(或对应前端 200)
|
||||||
|
|
||||||
|
说明:API 健康检查短暂 `FAIL`/`000` 多为重启中,以 `发版完成` + `exit_code: 0` 为准。
|
||||||
|
|
||||||
|
### 5. 回复用户
|
||||||
|
|
||||||
|
一句说明:已合并 commit、发版结果、建议微信强刷验证。
|
||||||
|
|
||||||
|
## 常用变体
|
||||||
|
|
||||||
|
| 场景 | 做法 |
|
||||||
|
|------|------|
|
||||||
|
| 仅部署已推送的 `dev` | 跳过 commit/merge,直接跑 `deploy.sh --branch "dev" -- --skip-db` |
|
||||||
|
| 检查服务器 | `deploy.sh --branch "dev" --check` |
|
||||||
|
| 含 DB | `deploy.sh --branch "dev" --`(无 skip-db)或按用户给 seed 参数 |
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- 不 amend 已推送 commit;不 force push `main`/`master`/`dev`
|
||||||
|
- 不把 `.env` / 私钥打进仓库
|
||||||
|
- 用户只说「修好」未说发布时 → **只改代码,不发版**
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
|
```
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
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` |
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 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:手机号建用户;权益自动发 |
|
||||||
|
| 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 规范;按角色脱敏 |
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 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-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-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-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 日异议处理 |
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 杜康好客 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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
@@ -4,7 +4,14 @@ dist/
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
server/dukang-api/.env
|
||||||
|
server/dukang-api/.env.development
|
||||||
|
server/dukang-api/.env.production
|
||||||
|
server/dukang-api/.env.production.local
|
||||||
|
deploy/deploy.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
server/dukang-api/prisma/migrations/
|
server/dukang-api/prisma/migrations/
|
||||||
|
debug_v3.xlsx
|
||||||
|
~$debug_v3.xlsx
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# 杜康好客 · 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 验证码:`123456`。测试账号见 [`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:待付款 | 已付款 | 已完成
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提交与 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,6 +1,6 @@
|
|||||||
# 杜康好客 preV1
|
# 杜康好客 V3 交付版
|
||||||
|
|
||||||
三端 H5 + NestJS Mock 联调版。完整规格见 `杜康好客-V2编码手册.md`,preV1 裁剪见 `杜康好客-preV1编码手册.md`。
|
四端 H5/WebAdmin + NestJS 单体 API。**V3 交付验收**以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准;V2 为完整蓝图参考;preV1 为 Mock 联调历史参考。
|
||||||
|
|
||||||
## 快速启动
|
## 快速启动
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd deploy
|
cd deploy
|
||||||
docker compose up -d
|
docker compose up -d # MySQL :6016 Redis :6017(与 dukang-v3 的 6014/6015 隔离)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 安装依赖
|
### 2. 安装依赖
|
||||||
@@ -23,8 +23,8 @@ pnpm install
|
|||||||
cp server/dukang-api/.env.example server/dukang-api/.env
|
cp server/dukang-api/.env.example server/dukang-api/.env
|
||||||
pnpm db:generate
|
pnpm db:generate
|
||||||
cd server/dukang-api
|
cd server/dukang-api
|
||||||
npx prisma db push
|
npx prisma db push # v3.1 首次切换加 --accept-data-loss
|
||||||
pnpm prisma:seed
|
pnpm prisma:seed # seed-v31.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 启动服务
|
### 4. 启动服务
|
||||||
@@ -37,6 +37,9 @@ pnpm dev:api
|
|||||||
pnpm dev:user # http://localhost:5173
|
pnpm dev:user # http://localhost:5173
|
||||||
pnpm dev:shop # http://localhost:5174
|
pnpm dev:shop # http://localhost:5174
|
||||||
pnpm dev:partner # http://localhost:5175
|
pnpm dev:partner # http://localhost:5175
|
||||||
|
|
||||||
|
|
||||||
|
改动测试
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试账号(验证码均为 123456)
|
## 测试账号(验证码均为 123456)
|
||||||
@@ -44,15 +47,20 @@ pnpm dev:partner # http://localhost:5175
|
|||||||
| 端 | 手机号 |
|
| 端 | 手机号 |
|
||||||
|----|--------|
|
|----|--------|
|
||||||
| C端用户 | 13800000001 |
|
| C端用户 | 13800000001 |
|
||||||
| 门店 | 13900000001 |
|
|
||||||
| 合伙人 | 13700000001 |
|
| 合伙人 | 13700000001 |
|
||||||
|
| HQ | 13600000001(密码登录:`admin` / `dukang@123!`;短信验证码 123456) |
|
||||||
|
|
||||||
|
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`。
|
||||||
|
|
||||||
## 主链路冒烟
|
## 主链路冒烟
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/smoke-prev1.mjs
|
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)
|
- `apps/h5-user` · `apps/h5-shop` · `apps/h5-partner` — 三端 H5(Vite + React)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 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,6 +1,9 @@
|
|||||||
# 杜康好客 · Cursor Agent 指引
|
# 杜康好客 · Cursor Agent 指引
|
||||||
|
|
||||||
> 本文件供 **下一个 Cursor Agent** 在编码前阅读。与 `skills.md`、`杜康好客-V2编码手册.md` 配合使用。
|
> 本文件供 **人类与 Agent** 在编码前阅读。
|
||||||
|
> **AI 自动加载入口**:[`AGENTS.md`](./AGENTS.md)(跨工具通用)
|
||||||
|
> **Skills / 子 Agent**:`.cursor/skills/`、`.cursor/agents/`(Cursor 2.4+)
|
||||||
|
> 与 [`skills.md`](./skills.md)、[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) 配合使用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,12 +20,14 @@
|
|||||||
|
|
||||||
## 2. 启动流程(每次会话)
|
## 2. 启动流程(每次会话)
|
||||||
|
|
||||||
1. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 §
|
1. 阅读 [`AGENTS.md`](./AGENTS.md) 确认 OWNER 边界与当前阶段(preV1)
|
||||||
2. 阅读 [`skills.md`](./skills.md) 中的编码检查清单
|
2. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 §
|
||||||
3. 阅读 [`conventions.md`](./conventions.md) 模块边界
|
3. 启用 Skill:`@dukang-coding`(通用)或 `@dukang-prev1` / `@dukang-task-card`(按需)
|
||||||
4. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ 在 preV1 §9 或 V2 §七 查验收标准
|
4. 按 OWNER 选择子 Agent:`/owner-a-user-trade` 等(见 AGENTS.md §子 Agent)
|
||||||
5. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq)
|
5. 阅读 [`conventions.md`](./conventions.md) 模块边界
|
||||||
6. 一次只完成一个任务卡;完成后自验 DoD
|
6. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ preV1 §9 或 V2 §七
|
||||||
|
7. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq 小程序;HQ 用 admin-web)
|
||||||
|
8. 一次只完成一个任务卡;PR 前可用 `/boundary-reviewer` 审查跨模块违规
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,15 +76,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 模块 OWNER(写代码时只改自己的目录)
|
## 6. 模块 OWNER(2 人团队)
|
||||||
|
|
||||||
| OWNER | App | Module |
|
| 负责人 | Git 账号 | App(preV1 → V2) | Module |
|
||||||
|-------|-----|--------|
|
|--------|----------|-------------------|--------|
|
||||||
| A | mini-user | iam, trade, benefit, analytics |
|
| Jacy | jacy-dukang | h5-user、admin-web → mini-user/mini-hq | iam, trade, benefit, analytics, catalog, settlement, ops |
|
||||||
| B | mini-partner | store |
|
| 刘景尧 | 刘景尧 | h5-partner、h5-shop → mini-partner | store, redeem |
|
||||||
| C | mini-hq | catalog, settlement, ops |
|
| Jacy(横切) | jacy-dukang | packages/*, callbacks/, jobs/, integrations/ | 基础设施 |
|
||||||
| D | h5-shop | redeem |
|
|
||||||
| Lead | packages/*, callbacks/, jobs/ | 横切 |
|
|
||||||
|
|
||||||
**禁止**:Module A 直写 Module B 的 Prisma 表;apps import server 源码。
|
**禁止**:Module A 直写 Module B 的 Prisma 表;apps import server 源码。
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@
|
|||||||
```text
|
```text
|
||||||
权益发放额 = common_product_item.benefit_amount ?? price
|
权益发放额 = common_product_item.benefit_amount ?? price
|
||||||
同城起购 2 瓶 / 跨城 6 瓶
|
同城起购 2 瓶 / 跨城 6 瓶
|
||||||
核销:0 < amount ≤ min(balance, 500)
|
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
|
||||||
C 端门店列表仅 status=OPEN
|
C 端门店列表仅 status=OPEN
|
||||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||||
支付回调幂等 → 发券 → common_event(BENEFIT_LEDGER, GRANT)
|
支付回调幂等 → 发券 → common_event(BENEFIT_LEDGER, GRANT)
|
||||||
@@ -132,4 +135,4 @@ dukang/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*编码时 @ 本文件或阅读 `skills.md`;需求变更只改 `杜康好客-V2编码手册.md`。*
|
*编码时 @ [`AGENTS.md`](./AGENTS.md) 或 Skill `dukang-coding`;需求变更只改 `杜康好客-V2编码手册.md`。*
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 杜康好客 · 前端 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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 StoresPage from './pages/StoresPage';
|
||||||
|
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 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 TicketsPage from './pages/TicketsPage';
|
||||||
|
import UserLogsPage from './pages/UserLogsPage';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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="/products" element={<ProductsPage />} />
|
||||||
|
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||||
|
<Route path="/stores" element={<StoresPage />} />
|
||||||
|
<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/partner-bills" element={<PartnerBillsPage />} />
|
||||||
|
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||||
|
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
|
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
|
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||||
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
|
<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="/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Cascader,
|
||||||
|
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 { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||||
|
|
||||||
|
type PartnerRow = {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
scopeType?: string;
|
||||||
|
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;
|
||||||
|
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 {
|
||||||
|
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||||
|
if (sum > maxRate + 1e-9) {
|
||||||
|
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CityPartnersPanel({ cityId, 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;
|
||||||
|
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?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '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="公司名" rules={[{ required: true }]}><Input /></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="地址" rules={[{ required: true }]}><Input /></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="区县">
|
||||||
|
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||||
|
</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 () => {
|
||||||
|
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?.();
|
||||||
|
}}>
|
||||||
|
<Form form={createForm} layout="vertical">
|
||||||
|
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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="地址" rules={[{ required: true }]}><Input /></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="区县" rules={[{ required: true }]}>
|
||||||
|
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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: '暂无子账号' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, 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 {
|
||||||
|
DashboardOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
ShoppingOutlined,
|
||||||
|
ShopOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
GiftOutlined,
|
||||||
|
CarOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
AccountBookOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
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: 'stores-group',
|
||||||
|
icon: <ShopOutlined />,
|
||||||
|
label: '门店',
|
||||||
|
children: [
|
||||||
|
{ key: '/stores', 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: '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', icon: <CarOutlined />, 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: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
<Sider
|
||||||
|
breakpoint="lg"
|
||||||
|
collapsedWidth={64}
|
||||||
|
theme="dark"
|
||||||
|
width={220}
|
||||||
|
style={{ height: '100vh', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||||
|
<div style={{ flexShrink: 0, padding: '16px', color: '#fff', fontWeight: 600 }}>
|
||||||
|
杜康 HQ
|
||||||
|
</div>
|
||||||
|
<div className="admin-sider-menu-scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
|
||||||
|
items={MENU_ITEMS}
|
||||||
|
onClick={({ key }) => {
|
||||||
|
if (key.startsWith('/')) navigate(key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
<Layout style={{ height: '100vh', 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
export const apiBase = '/api/v1';
|
||||||
|
export const CLIENT_APP = 'HQ_WEB';
|
||||||
|
|
||||||
|
export type HqProfile = {
|
||||||
|
id: string;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
adminRole: string;
|
||||||
|
status: 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;
|
||||||
|
ordersByStatus: Array<{ status: string; count: 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;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/** 与后端 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: '已退款',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 订单状态 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> = {
|
||||||
|
DRAFT: '草稿',
|
||||||
|
CONFIRMED: '已确认',
|
||||||
|
PAID: '已结算',
|
||||||
|
};
|
||||||
|
|
||||||
|
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') : '—';
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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: '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: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
||||||
|
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
||||||
|
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
|
||||||
|
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
|
||||||
|
{ value: 'PARTNER_BILL_MARK_PAID', 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||||
|
PARTNER_LOG_CATEGORY_LABELS,
|
||||||
|
resolvePartnerLogCategory,
|
||||||
|
type PartnerLogCategory,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/** 商品详情页文案模板(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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
STORE_LOG_CATEGORY_OPTIONS,
|
||||||
|
STORE_LOG_CATEGORY_LABELS,
|
||||||
|
resolveStoreLogCategory,
|
||||||
|
type StoreLogCategory,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export type StoreCreateForm = {
|
||||||
|
partnerAccountId: string;
|
||||||
|
cityId: string;
|
||||||
|
regionCodes?: string[];
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district: string;
|
||||||
|
districtCode?: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
intro?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
envPhotoUrls?: string[];
|
||||||
|
contractUrl?: string;
|
||||||
|
bankAccountName: string;
|
||||||
|
bankAccountNo: string;
|
||||||
|
bankBranch: string;
|
||||||
|
settlementRate?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHONE_RE = /^1\d{10}$/;
|
||||||
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
|
|
||||||
|
export function validateStoreCreateStep1(
|
||||||
|
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||||
|
): string | null {
|
||||||
|
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||||
|
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||||
|
if (!form.cityId) 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 '请填写详细地址';
|
||||||
|
if (form.intro?.trim()) {
|
||||||
|
const len = form.intro.trim().length;
|
||||||
|
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type UserLogCategory =
|
||||||
|
| 'login'
|
||||||
|
| 'browse_product'
|
||||||
|
| 'order'
|
||||||
|
| 'pay'
|
||||||
|
| 'wechat_auth'
|
||||||
|
| 'browse_store'
|
||||||
|
| 'redeem'
|
||||||
|
| 'profile';
|
||||||
|
|
||||||
|
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
||||||
|
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
|
||||||
|
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
||||||
|
order: ['order_confirm_view', 'order_submit'],
|
||||||
|
pay: ['pay_success', 'pay_fail'],
|
||||||
|
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
||||||
|
browse_store: ['store_list_view', 'store_detail_view'],
|
||||||
|
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
||||||
|
profile: ['profile_update', 'bind_phone'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'login', label: '登录' },
|
||||||
|
{ value: 'browse_product', label: '浏览商品' },
|
||||||
|
{ value: 'order', label: '下单' },
|
||||||
|
{ value: 'pay', label: '支付' },
|
||||||
|
{ value: 'wechat_auth', label: '微信授权' },
|
||||||
|
{ value: 'browse_store', label: '浏览门店' },
|
||||||
|
{ value: 'redeem', label: '核销' },
|
||||||
|
{ value: 'profile', label: '信息修改' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
|
||||||
|
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
|
||||||
|
[UserLogCategory, readonly string[]]
|
||||||
|
>) {
|
||||||
|
if (events.includes(eventName)) return category;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
|
||||||
|
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
|
||||||
|
) as Record<string, string>;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(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={560}
|
||||||
|
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="券号">{String(detail.couponNo)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户">
|
||||||
|
{String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机号">
|
||||||
|
{String((detail.user as { phone?: string } | undefined)?.phone ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联订单">
|
||||||
|
{String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
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 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 };
|
||||||
|
|
||||||
|
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 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 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 },
|
||||||
|
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||||
|
管理
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'partners',
|
||||||
|
label: '合伙人',
|
||||||
|
children: detail?.id ? (
|
||||||
|
<CityPartnersPanel
|
||||||
|
cityId={String(detail.id)}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Cascader,
|
||||||
|
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 { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||||
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
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;
|
||||||
|
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 {
|
||||||
|
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||||
|
if (sum > maxRate + 1e-9) {
|
||||||
|
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 loadCities = useCallback(async () => {
|
||||||
|
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
|
setCities(res.items);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
setMaxCommissionRate(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;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
|
||||||
|
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||||
|
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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);
|
||||||
|
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="companyName" label="公司">
|
||||||
|
<Input allowClear placeholder="公司名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机">
|
||||||
|
<Input allowClear placeholder="登录手机" />
|
||||||
|
</Form.Item>
|
||||||
|
<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>
|
||||||
|
<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="门店数">{detail.storeCount ?? 0}</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="公司名" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</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="地址" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</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="区县">
|
||||||
|
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||||
|
</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 () => {
|
||||||
|
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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="公司名" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</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="地址" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</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="区县" rules={[{ required: true }]}>
|
||||||
|
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
request,
|
||||||
|
type DashboardStats,
|
||||||
|
type DeployTriggerResult,
|
||||||
|
type HqProfile,
|
||||||
|
type SystemVersion,
|
||||||
|
} from '../lib/api';
|
||||||
|
import { 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 发布',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 loadVersion = useCallback(() => {
|
||||||
|
setVersionLoading(true);
|
||||||
|
return request<SystemVersion | null>('/admin/dashboard/version')
|
||||||
|
.then(setVersion)
|
||||||
|
.catch(() => setVersion(null))
|
||||||
|
.finally(() => setVersionLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<DashboardStats>('/admin/dashboard/stats')
|
||||||
|
.then(setStats)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
void loadVersion();
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
|
}, [loadVersion]);
|
||||||
|
|
||||||
|
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) : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="系统版本"
|
||||||
|
loading={versionLoading}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
|
||||||
|
刷新版本
|
||||||
|
</Button>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="待处理工单">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
FULFILLMENT_PROVIDER_STATUS_LABELS,
|
||||||
|
FULFILLMENT_PROVIDER_TYPE_LABELS,
|
||||||
|
FulfillmentProviderStatus,
|
||||||
|
FulfillmentProviderType,
|
||||||
|
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 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',
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: FulfillmentProviderDto) {
|
||||||
|
setEditRow(row);
|
||||||
|
const xfx = row.xiaofeixiaConfig;
|
||||||
|
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 || '',
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const v = await form.validateFields();
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: v.name,
|
||||||
|
type: v.type,
|
||||||
|
status: v.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
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: '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">
|
||||||
|
注册第三方履约接口并填写凭证;仓库绑定后,推单将使用此处配置的 API 地址与密钥
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
注册承运商
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editRow ? '编辑承运商' : '注册承运商'}
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
width={560}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{showXfxFields && (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 8 }}>
|
||||||
|
小飞侠接口参数
|
||||||
|
</Typography.Title>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
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]));
|
||||||
|
|
||||||
|
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[])}
|
||||||
|
>
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
{HQ_PERMISSION_CATALOG.map((item) => (
|
||||||
|
<Col key={item.key} span={8}>
|
||||||
|
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</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 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
|
||||||
|
.filter((a) => a.adminRole !== 'SUPER_ADMIN')
|
||||||
|
.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 账户" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Space wrap style={{ marginBottom: 12 }}>
|
||||||
|
<span>角色继承:</span>
|
||||||
|
{roleInheritedKeys.map((key) => {
|
||||||
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||||
|
return (
|
||||||
|
<Tag key={key} color="blue">
|
||||||
|
{item?.label || key}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
||||||
|
<Space wrap style={{ marginTop: 12 }}>
|
||||||
|
<span>合并生效:</span>
|
||||||
|
{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 style={{ marginTop: 16 }}>
|
||||||
|
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
||||||
|
保存用户权限
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
|
||||||
|
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: '123456' }}
|
||||||
|
>
|
||||||
|
<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="123456"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,820 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Collapse,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||||
|
import {
|
||||||
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
|
DELIVERY_PROVIDER_LABELS,
|
||||||
|
ORDER_STATUS_COLORS,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
fmtTime,
|
||||||
|
} from '../lib/constants';
|
||||||
|
|
||||||
|
type ShipDefaults = {
|
||||||
|
provider: string;
|
||||||
|
providerLabel: string;
|
||||||
|
fromName: string;
|
||||||
|
fromMobile: string;
|
||||||
|
fromAddress: string;
|
||||||
|
fromAddressDetail: string;
|
||||||
|
fromLng: number;
|
||||||
|
fromLat: number;
|
||||||
|
weight: number;
|
||||||
|
payMode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CityOption = { id: string; name: string; code: string };
|
||||||
|
|
||||||
|
type WarehouseOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cityId?: string;
|
||||||
|
cityName?: string;
|
||||||
|
status?: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
address: string;
|
||||||
|
lng?: number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrderDetail = AdminOrderRow & {
|
||||||
|
receiverAddress?: string;
|
||||||
|
receiverProvince?: string;
|
||||||
|
receiverCity?: string;
|
||||||
|
receiverDistrict?: string;
|
||||||
|
clientIp?: string | null;
|
||||||
|
ipProvince?: string | null;
|
||||||
|
ipCity?: string | null;
|
||||||
|
ipDistrict?: string | null;
|
||||||
|
gpsProvince?: string | null;
|
||||||
|
gpsCity?: string | null;
|
||||||
|
gpsDistrict?: string | null;
|
||||||
|
gpsLatitude?: number | null;
|
||||||
|
gpsLongitude?: number | null;
|
||||||
|
gpsAddress?: string | null;
|
||||||
|
productAmount?: number;
|
||||||
|
freightAmount?: number;
|
||||||
|
benefitAmount?: number;
|
||||||
|
paidAt?: string | null;
|
||||||
|
payExpireAt?: string | null;
|
||||||
|
productImage?: string;
|
||||||
|
listUnitPrice?: number;
|
||||||
|
items?: AdminOrderItem[];
|
||||||
|
payment?: Record<string, unknown> | null;
|
||||||
|
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
||||||
|
benefitCoupons?: Array<Record<string, unknown>>;
|
||||||
|
fulfillmentWarehouse?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contactName?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
address?: string;
|
||||||
|
lng?: number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
|
||||||
|
|
||||||
|
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
|
||||||
|
if (detail.items?.length) return detail.items;
|
||||||
|
if (!detail.productName) return [];
|
||||||
|
return [{
|
||||||
|
productName: detail.productName,
|
||||||
|
productSpec: detail.productSpec ?? '',
|
||||||
|
productImage: detail.productImage ?? '',
|
||||||
|
unitPrice: Number(detail.listUnitPrice ?? 0),
|
||||||
|
quantity: detail.quantity ?? 1,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function canShip(row: { status: string; delivery?: { trackingNo?: string | null } | null }) {
|
||||||
|
return ['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(row.status) && !row.delivery?.trackingNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): ShipDefaults {
|
||||||
|
return {
|
||||||
|
provider: 'XFX',
|
||||||
|
providerLabel: '小飞侠',
|
||||||
|
fromName: wh.contactName || base?.fromName || '杜康仓库',
|
||||||
|
fromMobile: wh.contactPhone || base?.fromMobile || '13800000000',
|
||||||
|
fromAddress: wh.address || base?.fromAddress || '',
|
||||||
|
fromAddressDetail: wh.name || base?.fromAddressDetail || '',
|
||||||
|
fromLng: wh.lng != null ? Number(wh.lng) : (base?.fromLng ?? 113.665),
|
||||||
|
fromLat: wh.lat != null ? Number(wh.lat) : (base?.fromLat ?? 34.757),
|
||||||
|
weight: base?.weight ?? 2,
|
||||||
|
payMode: base?.payMode ?? '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [shipForm] = Form.useForm();
|
||||||
|
const [logisticsForm] = Form.useForm();
|
||||||
|
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||||
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
|
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
||||||
|
const [shipping, setShipping] = useState(false);
|
||||||
|
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [shipModalOpen, setShipModalOpen] = useState(false);
|
||||||
|
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
|
||||||
|
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||||
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||||
|
|
||||||
|
const selectedOrders = useMemo(
|
||||||
|
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||||
|
[data?.items, selectedRowKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
|
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||||
|
if (values.status) qs.set('status', values.status);
|
||||||
|
if (values.cityId) qs.set('cityId', values.cityId);
|
||||||
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
|
setData(res);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [form, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
|
||||||
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
|
||||||
|
shipForm.setFieldsValue({
|
||||||
|
warehouseId: warehouseId || undefined,
|
||||||
|
provider: defaults.provider,
|
||||||
|
weight: defaults.weight,
|
||||||
|
payMode: defaults.payMode,
|
||||||
|
fromName: defaults.fromName,
|
||||||
|
fromMobile: defaults.fromMobile,
|
||||||
|
fromAddress: defaults.fromAddress,
|
||||||
|
fromAddressDetail: defaults.fromAddressDetail,
|
||||||
|
fromLng: defaults.fromLng,
|
||||||
|
fromLat: defaults.fromLat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWarehouses() {
|
||||||
|
const res = await request<Paginated<WarehouseOption>>(
|
||||||
|
`/admin/city-warehouses?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&status=ACTIVE`,
|
||||||
|
).catch(() => null);
|
||||||
|
const rows = (res?.items ?? []).filter((w) => !w.status || w.status === 'ACTIVE');
|
||||||
|
setWarehouses(rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openShipModal(id: string) {
|
||||||
|
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
||||||
|
setShipTarget(res);
|
||||||
|
setDetail(res);
|
||||||
|
logisticsForm.setFieldsValue({
|
||||||
|
logisticsCompany: '',
|
||||||
|
trackingNo: '',
|
||||||
|
manualQueryUrl: '',
|
||||||
|
});
|
||||||
|
const rows = await loadWarehouses();
|
||||||
|
const preferredWarehouseId = res.fulfillmentWarehouseId || res.fulfillmentWarehouse?.id;
|
||||||
|
const preferred =
|
||||||
|
rows.find((w) => w.id === preferredWarehouseId) ||
|
||||||
|
rows.find((w) => w.cityId === (res.city?.id || res.cityId)) ||
|
||||||
|
rows[0];
|
||||||
|
const defaults = preferred
|
||||||
|
? warehouseToDefaults(preferred, shipDefaults)
|
||||||
|
: (shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null));
|
||||||
|
if (defaults) {
|
||||||
|
setShipDefaults(defaults);
|
||||||
|
applyShipDefaults(defaults, preferred?.id);
|
||||||
|
} else {
|
||||||
|
shipForm.setFieldsValue({ warehouseId: preferred?.id, provider: 'XFX' });
|
||||||
|
}
|
||||||
|
setShipMode(preferred ? 'WAREHOUSE' : 'EXPRESS');
|
||||||
|
setShipModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onWarehouseChange(warehouseId: string) {
|
||||||
|
const wh = warehouses.find((w) => w.id === warehouseId);
|
||||||
|
if (!wh) return;
|
||||||
|
applyShipDefaults(warehouseToDefaults(wh, shipDefaults), warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitShip() {
|
||||||
|
if (!shipTarget) return;
|
||||||
|
const values = await shipForm.validateFields();
|
||||||
|
setShipping(true);
|
||||||
|
try {
|
||||||
|
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/ship`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: values.provider || 'XFX',
|
||||||
|
warehouseId: values.warehouseId,
|
||||||
|
weight: values.weight,
|
||||||
|
payMode: values.payMode,
|
||||||
|
remark: values.remark,
|
||||||
|
fromName: values.fromName,
|
||||||
|
fromMobile: values.fromMobile,
|
||||||
|
fromAddress: values.fromAddress,
|
||||||
|
fromAddressDetail: values.fromAddressDetail,
|
||||||
|
fromLng: values.fromLng,
|
||||||
|
fromLat: values.fromLat,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('选仓发货成功');
|
||||||
|
setShipTarget(res);
|
||||||
|
setDetail(res);
|
||||||
|
setShipModalOpen(false);
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '发货失败');
|
||||||
|
} finally {
|
||||||
|
setShipping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLogisticsShip() {
|
||||||
|
if (!shipTarget) return;
|
||||||
|
const values = await logisticsForm.validateFields(['logisticsCompany', 'trackingNo', 'manualQueryUrl']);
|
||||||
|
const logisticsCompany = String(values.logisticsCompany ?? '').trim();
|
||||||
|
const trackingNo = String(values.trackingNo ?? '').trim();
|
||||||
|
if (!logisticsCompany || !trackingNo) {
|
||||||
|
message.error('请填写快递公司与运单号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogisticsShipping(true);
|
||||||
|
try {
|
||||||
|
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/logistics-ship`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
logisticsCompany,
|
||||||
|
trackingNo,
|
||||||
|
manualQueryUrl: String(values.manualQueryUrl ?? '').trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('快递单已录入');
|
||||||
|
setShipTarget(res);
|
||||||
|
setDetail(res);
|
||||||
|
setShipModalOpen(false);
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '填单失败');
|
||||||
|
} finally {
|
||||||
|
setLogisticsShipping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBatchDelete() {
|
||||||
|
if (!selectedRowKeys.length) return;
|
||||||
|
setBatchDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ deleted: number; message: string }>('/admin/orders/batch-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: selectedRowKeys }),
|
||||||
|
});
|
||||||
|
message.success(res.message || `已删除 ${res.deleted} 笔订单`);
|
||||||
|
setBatchDeleteOpen(false);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
if (detail && selectedRowKeys.includes(detail.id)) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '批量删除失败');
|
||||||
|
} finally {
|
||||||
|
setBatchDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<AdminOrderRow> = [
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||||
|
{
|
||||||
|
title: '城市',
|
||||||
|
width: 90,
|
||||||
|
render: (_, row) => row.city?.name || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) => {
|
||||||
|
const name = row.productName || '—';
|
||||||
|
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
|
||||||
|
return (
|
||||||
|
<span title={row.productSpec ? `${name}(${row.productSpec})` : name}>
|
||||||
|
{name}{qty}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s) => (
|
||||||
|
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配送',
|
||||||
|
dataIndex: 'deliveryType',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => (v === 'LOCAL' ? '同城' : '跨城'),
|
||||||
|
},
|
||||||
|
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
||||||
|
{ title: '手机', dataIndex: 'receiverPhone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: ['user', 'userNo'],
|
||||||
|
width: 110,
|
||||||
|
render: (_, row) => row.user?.userNo || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '快递',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '下单时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 170,
|
||||||
|
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
{canShip(row) && (
|
||||||
|
<Button type="link" size="small" onClick={() => void openShipModal(row.id)}>
|
||||||
|
配送
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
onClick={() => setBatchDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
|
<Form.Item name="orderNo" label="订单号">
|
||||||
|
<Input placeholder="DK..." allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" label="城市">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
placeholder="全部"
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="receiverPhone" label="收货手机">
|
||||||
|
<Input allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1500 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
|
}}
|
||||||
|
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 && (
|
||||||
|
<Space>
|
||||||
|
{canShip(detail) && (
|
||||||
|
<Button type="primary" onClick={() => void openShipModal(detail.id)}>
|
||||||
|
配送发货
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Typography.Text type="secondary">调试改状态</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={detail.status}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
onChange={async (status) => {
|
||||||
|
await request(`/admin/orders/${detail.id}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
message.success('状态已更新(调试)');
|
||||||
|
const res = await request<OrderDetail>(`/admin/orders/${detail.id}`);
|
||||||
|
setDetail(res);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} bordered size="small" title="基本信息">
|
||||||
|
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开城城市">
|
||||||
|
{detail.city?.name || '—'}
|
||||||
|
{detail.city?.code ? `(${detail.city.code})` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="履约仓">
|
||||||
|
{detail.fulfillmentWarehouse?.name || '未分配'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||||
|
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>商品</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey={(_, i) => String(i)}
|
||||||
|
pagination={false}
|
||||||
|
dataSource={orderProductRows(detail)}
|
||||||
|
locale={{ emptyText: '无商品信息' }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
dataIndex: 'productName',
|
||||||
|
render: (name: string, row) => (
|
||||||
|
<Space>
|
||||||
|
{row.productImage ? (
|
||||||
|
<img
|
||||||
|
src={row.productImage}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>
|
||||||
|
{name || '—'}
|
||||||
|
{row.productSpec ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{row.productSpec}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单价',
|
||||||
|
dataIndex: 'unitPrice',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => `¥${v}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
|
||||||
|
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Descriptions column={1} bordered size="small" title="位置快照(方案C)" style={{ marginTop: 16 }}>
|
||||||
|
<Descriptions.Item label="clientIp">{detail.clientIp || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="IP解析">{[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="GPS">{[detail.gpsProvince, detail.gpsCity, detail.gpsDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="坐标">
|
||||||
|
{detail.gpsLatitude != null ? `${detail.gpsLatitude}, ${detail.gpsLongitude}` : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{detail.delivery && (
|
||||||
|
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||||
|
<Descriptions.Item label="快递公司">
|
||||||
|
{detail.delivery.logisticsCompany ||
|
||||||
|
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
||||||
|
detail.delivery.provider}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
||||||
|
{detail.delivery.manualQueryUrl && (
|
||||||
|
<Descriptions.Item label="查询链接">
|
||||||
|
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||||
|
打开物流查询
|
||||||
|
</a>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="createdAt"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={detail.statusLogs}
|
||||||
|
columns={[
|
||||||
|
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
|
||||||
|
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={shipTarget ? `配送发货 · ${shipTarget.orderNo}` : '配送发货'}
|
||||||
|
open={shipModalOpen}
|
||||||
|
onCancel={() => setShipModalOpen(false)}
|
||||||
|
width={560}
|
||||||
|
destroyOnClose={false}
|
||||||
|
forceRender
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setShipModalOpen(false)}>取消</Button>
|
||||||
|
{shipMode === 'WAREHOUSE' ? (
|
||||||
|
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
|
||||||
|
确认选仓发货
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
|
||||||
|
提交快递单
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shipTarget && (
|
||||||
|
<>
|
||||||
|
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收货">
|
||||||
|
{shipTarget.receiverName} {shipTarget.receiverPhone}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商品">
|
||||||
|
{shipTarget.productName || '—'}
|
||||||
|
{shipTarget.quantity != null ? ` ×${shipTarget.quantity}` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Radio.Group
|
||||||
|
value={shipMode}
|
||||||
|
onChange={(e) => setShipMode(e.target.value as ShipMode)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'WAREHOUSE', label: '选仓配送' },
|
||||||
|
{ value: 'EXPRESS', label: '填写快递单号' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: shipMode === 'WAREHOUSE' ? 'block' : 'none' }}>
|
||||||
|
<Form form={shipForm} layout="vertical" size="small" preserve>
|
||||||
|
<Form.Item
|
||||||
|
name="warehouseId"
|
||||||
|
label="选择仓库"
|
||||||
|
rules={[{ required: true, message: '请选择仓库' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder={warehouses.length ? '选择仓库' : '暂无可用仓库'}
|
||||||
|
options={warehouses.map((w) => ({
|
||||||
|
value: w.id,
|
||||||
|
label: w.cityName ? `${w.cityName} · ${w.name}` : w.name,
|
||||||
|
}))}
|
||||||
|
onChange={(id) => void onWarehouseChange(id)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="provider" label="承运商" rules={[{ required: true }]} initialValue="XFX">
|
||||||
|
<Select options={[{ value: 'XFX', label: '小飞侠' }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '1', label: '寄付' },
|
||||||
|
{ value: '2', label: '到付' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
items={[{
|
||||||
|
key: 'from',
|
||||||
|
label: '寄件信息(默认仓库,可修改)',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="fromLng" label="经度">
|
||||||
|
<InputNumber step={0.001} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromLat" label="纬度">
|
||||||
|
<InputNumber step={0.001} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: shipMode === 'EXPRESS' ? 'block' : 'none' }}>
|
||||||
|
<Form form={logisticsForm} layout="vertical" size="small" preserve>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 0 }}>
|
||||||
|
填写快递公司与运单号即可完成发货
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form.Item
|
||||||
|
name="logisticsCompany"
|
||||||
|
label="快递公司"
|
||||||
|
rules={[{ required: true, message: '请填写快递公司' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="如 顺丰速运、京东物流" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="trackingNo"
|
||||||
|
label="运单号"
|
||||||
|
rules={[{ required: true, message: '请填写运单号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入运单号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
|
||||||
|
<Input placeholder="https://..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`确认批量删除(${selectedOrders.length} 笔)`}
|
||||||
|
open={batchDeleteOpen}
|
||||||
|
okText="确认删除"
|
||||||
|
okButtonProps={{ danger: true, loading: batchDeleting }}
|
||||||
|
onOk={() => void confirmBatchDelete()}
|
||||||
|
onCancel={() => setBatchDeleteOpen(false)}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="此操作不可恢复"
|
||||||
|
description="将删除所选订单及其配送单、权益券、核销记录等关联业务数据。订单状态流转等业务日志将保留。"
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 600, y: 280 }}
|
||||||
|
dataSource={selectedOrders}
|
||||||
|
columns={[
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
|
||||||
|
{
|
||||||
|
title: '城市',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => row.city?.name || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.productName
|
||||||
|
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s) => (
|
||||||
|
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
||||||
|
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
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> = {
|
||||||
|
DRAFT: '待合伙人确认',
|
||||||
|
CONFIRMED: '待打款审核',
|
||||||
|
PAID: '已打款',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
DRAFT: 'default',
|
||||||
|
CONFIRMED: 'orange',
|
||||||
|
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);
|
||||||
|
|
||||||
|
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; all?: boolean }) {
|
||||||
|
const year = values.month.year();
|
||||||
|
const month = values.month.month() + 1;
|
||||||
|
if (values.all || !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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBill(id: string) {
|
||||||
|
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
||||||
|
message.success('已代确认,进入待打款审核');
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markPaid(id: string) {
|
||||||
|
await request(`/admin/partner-bills/${id}/mark-paid`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||||
|
});
|
||||||
|
message.success('已通过并标记打款');
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReject(row: Row) {
|
||||||
|
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 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: 120,
|
||||||
|
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 === 'DRAFT' && (
|
||||||
|
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
|
||||||
|
代确认
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{row.status === 'CONFIRMED' && (
|
||||||
|
<>
|
||||||
|
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
|
||||||
|
通过打款
|
||||||
|
</Button>
|
||||||
|
<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">
|
||||||
|
T+30 结算:合伙人确认并申请打款后,总部在此审核通过或驳回(驳回须填写理由)
|
||||||
|
</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: 140 }}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1280 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal title="生成合伙人账单(T+30)" 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) : ''}
|
||||||
|
<br />
|
||||||
|
驳回理由将展示在合伙人 H5 账单页。
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Cascader,
|
||||||
|
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 { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||||
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
contactPhone?: string | null;
|
||||||
|
cityId?: string | null;
|
||||||
|
cityName?: string | null;
|
||||||
|
scopeType?: string;
|
||||||
|
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 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;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||||
|
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||||
|
{ 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="公司名" rules={[{ required: true }]}><Input /></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="地址" rules={[{ required: true }]}><Input /></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="区县">
|
||||||
|
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||||
|
</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 () => {
|
||||||
|
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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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); void loadWarehouses(id); }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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="地址" rules={[{ required: true }]}><Input /></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="区县" rules={[{ required: true }]}>
|
||||||
|
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
coverUrl?: string;
|
||||||
|
carouselUrls?: string[];
|
||||||
|
detailImageUrls?: string[];
|
||||||
|
storyTitle?: string;
|
||||||
|
storyText?: string;
|
||||||
|
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapDetailToForm(d: Record<string, unknown>) {
|
||||||
|
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||||
|
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
|
||||||
|
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
skuCode: v.skuCode,
|
||||||
|
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,
|
||||||
|
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 BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mode === 'create' && (
|
||||||
|
<>
|
||||||
|
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</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="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> = [
|
||||||
|
{ 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>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
|
||||||
|
{ 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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: 1140 }}
|
||||||
|
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();
|
||||||
|
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" /> },
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
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,
|
||||||
|
carouselUrls: [''], detailImageUrls: [''],
|
||||||
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
|
}}>
|
||||||
|
<Tabs items={[
|
||||||
|
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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
|
||||||
|
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
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 默认 123456" />
|
||||||
|
</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 默认 123456" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
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; 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);
|
||||||
|
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: ['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>
|
||||||
|
<Table rowKey="id" 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); } }} />
|
||||||
|
<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="核销额">¥{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Descriptions, Drawer, Form, Input, Modal, 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[]>([]);
|
||||||
|
|
||||||
|
async function loadStores() {
|
||||||
|
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
|
setStores(res.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DatePicker,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import 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;
|
||||||
|
redeemAmount: number;
|
||||||
|
payoutAmount: number;
|
||||||
|
settlementRate: number;
|
||||||
|
status: string;
|
||||||
|
expectedPayAt: string;
|
||||||
|
paidAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||||
|
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreOption = { id: string; name: string; phone: string };
|
||||||
|
|
||||||
|
const PAYOUT_STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: '待打款',
|
||||||
|
PAID: '已打款',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYOUT_STATUS_COLORS: Record<string, string> = {
|
||||||
|
PENDING: 'orange',
|
||||||
|
PAID: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StoreBillsPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/store-payouts',
|
||||||
|
() => {
|
||||||
|
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);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setStores(res.items))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function confirmPayout(id: string) {
|
||||||
|
await request(`/admin/store-payouts/${id}/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ remark: '财务确认打款' }),
|
||||||
|
});
|
||||||
|
message.success('已确认打款');
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
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-payouts/export?${qs}`,
|
||||||
|
);
|
||||||
|
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||||
|
message.success(`已导出 ${result.count} 条`);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = data?.summary;
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{
|
||||||
|
title: '核销日期',
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) =>
|
||||||
|
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
|
||||||
|
},
|
||||||
|
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||||||
|
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
|
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||||
|
{
|
||||||
|
title: '核销单号',
|
||||||
|
dataIndex: ['redeemRecord', 'redeemNo'],
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
|
||||||
|
{
|
||||||
|
title: '核销比例',
|
||||||
|
dataIndex: 'settlementRate',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '应付门店',
|
||||||
|
dataIndex: 'payoutAmount',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => `¥${v}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '打款状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
{row.status === 'PENDING' && (
|
||||||
|
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
|
||||||
|
确认打款
|
||||||
|
</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">
|
||||||
|
T+1 结算:按日列出门店核销订单,应付 = 核销面额 × 门店核销比例
|
||||||
|
</Typography.Text>
|
||||||
|
</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} prefix="¥" precision={2} />
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v: {
|
||||||
|
storeId?: string;
|
||||||
|
status?: string;
|
||||||
|
dateRange?: [Dayjs, Dayjs];
|
||||||
|
}) => {
|
||||||
|
setFilters({
|
||||||
|
storeId: v.storeId || '',
|
||||||
|
status: v.status || '',
|
||||||
|
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
|
||||||
|
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="打款状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
placeholder="全部"
|
||||||
|
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="dateRange" label="核销日期">
|
||||||
|
<DatePicker.RangePicker />
|
||||||
|
</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 loading={exporting} onClick={() => void exportExcel()}>
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1300 }}
|
||||||
|
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.store as { name?: string })?.name ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销单号">
|
||||||
|
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结算比例">
|
||||||
|
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="实际打款">
|
||||||
|
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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 {
|
||||||
|
STORE_LOG_CATEGORY_OPTIONS,
|
||||||
|
STORE_LOG_CATEGORY_LABELS,
|
||||||
|
resolveStoreLogCategory,
|
||||||
|
type StoreLogCategory,
|
||||||
|
} from '../lib/store-log';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
source: 'analytics' | 'redeem_record' | 'store_payout';
|
||||||
|
storeId: string;
|
||||||
|
storeAccountId: string | null;
|
||||||
|
storeName: string | null;
|
||||||
|
accountName: string | null;
|
||||||
|
accountPhone: string | null;
|
||||||
|
category: StoreLogCategory | null;
|
||||||
|
eventName: string;
|
||||||
|
clientApp: string | null;
|
||||||
|
refType: string | null;
|
||||||
|
refId: string | null;
|
||||||
|
extraJson: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<Row['source'], string> = {
|
||||||
|
analytics: '行为埋点',
|
||||||
|
redeem_record: '核销记录',
|
||||||
|
store_payout: '打款记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||||
|
if (!json) return '—';
|
||||||
|
const text = JSON.stringify(json);
|
||||||
|
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCompositeId(id: string) {
|
||||||
|
const idx = id.indexOf(':');
|
||||||
|
if (idx <= 0) return null;
|
||||||
|
return { source: id.slice(0, idx), rawId: id.slice(idx + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoreLogsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||||
|
storeId: searchParams.get('storeId') ?? '',
|
||||||
|
storeAccountId: searchParams.get('storeAccountId') ?? '',
|
||||||
|
phone: searchParams.get('phone') ?? '',
|
||||||
|
storeName: searchParams.get('storeName') ?? '',
|
||||||
|
eventName: searchParams.get('eventName') ?? '',
|
||||||
|
}));
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
|
'/admin/logs/stores',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
|
if (filters.storeAccountId) qs.set('storeAccountId', filters.storeAccountId);
|
||||||
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
|
if (filters.storeName) qs.set('storeName', filters.storeName);
|
||||||
|
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) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账号', width: 150, ellipsis: true,
|
||||||
|
render: (_, r) => (
|
||||||
|
<AdminCellLine
|
||||||
|
primary={r.accountName}
|
||||||
|
secondary={r.accountPhone || r.storeAccountId || '系统/HQ'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类', dataIndex: 'category', width: 100,
|
||||||
|
render: (v: StoreLogCategory | null, r) => (
|
||||||
|
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
||||||
|
{
|
||||||
|
title: '来源', dataIndex: 'source', width: 100,
|
||||||
|
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 () => {
|
||||||
|
const parsed = parseCompositeId(row.id);
|
||||||
|
if (!parsed) return;
|
||||||
|
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}>详情</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>商户日志</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||||
|
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Segmented
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
|
value={category}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCategory(String(v));
|
||||||
|
setPage(1);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (v) next.set('category', String(v));
|
||||||
|
else next.delete('category');
|
||||||
|
setSearchParams(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
for (const key of ['storeId', 'storeAccountId', 'phone', 'storeName', 'eventName'] as const) {
|
||||||
|
if (v[key]) next.set(key, v[key]);
|
||||||
|
else next.delete(key);
|
||||||
|
}
|
||||||
|
setSearchParams(next);
|
||||||
|
}}>
|
||||||
|
<Form.Item name="storeId" label="门店ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
|
||||||
|
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||||
|
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item>
|
||||||
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table rowKey="id" 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); } }} />
|
||||||
|
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店">{String(detail.storeName || detail.storeId || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">
|
||||||
|
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="参数">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { 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 { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import OssUpload from '../components/OssUpload';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
|
||||||
|
store?: { id: string; name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreOption = { id: string; name: string };
|
||||||
|
|
||||||
|
export default function StoreMediaPage() {
|
||||||
|
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/store-media',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
|
if (filters.mediaType) qs.set('mediaType', filters.mediaType);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Row | null>(null);
|
||||||
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
|
||||||
|
async function loadStores() {
|
||||||
|
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
|
setStores(res.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||||
|
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
||||||
|
{
|
||||||
|
title: '预览', dataIndex: 'url', width: 100,
|
||||||
|
render: (url, row) => row.mediaType === 'IMAGE'
|
||||||
|
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
|
||||||
|
: <a href={url} target="_blank" rel="noreferrer">视频</a>,
|
||||||
|
},
|
||||||
|
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||||
|
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作', width: 120,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => {
|
||||||
|
setEditing(row);
|
||||||
|
editForm.setFieldsValue(row);
|
||||||
|
setEditOpen(true);
|
||||||
|
}}>编辑</Button>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={async () => {
|
||||||
|
await request(`/admin/store-media/${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={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>
|
||||||
|
</Space>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
|
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||||
|
<Form.Item name="mediaType" label="类型">
|
||||||
|
<Select allowClear style={{ width: 100 }} options={Object.entries(MEDIA_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: 900 }}
|
||||||
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
|
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||||
|
const v = await createForm.validateFields();
|
||||||
|
await request('/admin/store-media', { 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="mediaType" label="类型" rules={[{ required: true }]} initialValue="IMAGE">
|
||||||
|
<Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
|
||||||
|
{({ getFieldValue }) => (
|
||||||
|
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
|
||||||
|
<OssUpload bizType="STORE_ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序" initialValue={0}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal title="编辑门店资源" open={editOpen} onCancel={() => setEditOpen(false)} onOk={async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const v = await editForm.validateFields();
|
||||||
|
await request(`/admin/store-media/${editing.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||||
|
message.success('已保存');
|
||||||
|
setEditOpen(false);
|
||||||
|
void reload();
|
||||||
|
}}>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="mediaType" label="类型"><Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} /></Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
|
||||||
|
{({ getFieldValue }) => (
|
||||||
|
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
|
||||||
|
<OssUpload bizType="STORE_ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Steps,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
import {
|
||||||
|
validateStoreCreateStep1,
|
||||||
|
validateStoreCreateStep3,
|
||||||
|
type StoreCreateForm,
|
||||||
|
} from '../lib/storeCreate';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
|
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||||
|
import OssUpload from '../components/OssUpload';
|
||||||
|
|
||||||
|
const CREATE_STEPS = [
|
||||||
|
{ title: '基本信息' },
|
||||||
|
{ title: '照片上传' },
|
||||||
|
{ title: '结算资质' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type StoreRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
status: string;
|
||||||
|
auditStatus?: string;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
cityName: string;
|
||||||
|
district: string;
|
||||||
|
address: string;
|
||||||
|
intro: string | null;
|
||||||
|
coverUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
cityRef?: { name: string; code: string };
|
||||||
|
partner?: { companyName: string };
|
||||||
|
account?: { phone: string; name: string; status: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartnerOption = { id: string; companyName: string };
|
||||||
|
type CityOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StoresPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||||
|
'/admin/stores',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.name) qs.set('name', filters.name);
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||||
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const [auditing, setAuditing] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createStep, setCreateStep] = useState(0);
|
||||||
|
const [createError, setCreateError] = useState('');
|
||||||
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
|
|
||||||
|
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||||
|
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||||
|
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||||
|
|
||||||
|
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
||||||
|
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||||
|
if (!binding) {
|
||||||
|
createForm.setFieldsValue({ regionCodes: codes, cityId: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createForm.setFieldsValue({
|
||||||
|
regionCodes: codes,
|
||||||
|
province: binding.region.province,
|
||||||
|
city: binding.region.city,
|
||||||
|
district: binding.region.district,
|
||||||
|
districtCode: binding.region.districtCode,
|
||||||
|
cityId: binding.cityId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionBindingHint = useMemo(() => {
|
||||||
|
if (!selectedRegionCodes?.length) return null;
|
||||||
|
const binding = resolveRegionBinding(selectedRegionCodes, cities, selectedPartnerId);
|
||||||
|
if (!binding) return null;
|
||||||
|
if (binding.cityId && binding.matchedCity) {
|
||||||
|
return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode})`;
|
||||||
|
}
|
||||||
|
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
|
||||||
|
}, [selectedRegionCodes, cities, selectedPartnerId]);
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
setOptionsLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
||||||
|
const [p, c] = await Promise.all([
|
||||||
|
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
|
||||||
|
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
|
||||||
|
]);
|
||||||
|
setPartners(p.items);
|
||||||
|
setCities(c.items);
|
||||||
|
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||||
|
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
|
||||||
|
} finally {
|
||||||
|
setOptionsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateStep(0);
|
||||||
|
setCreateError('');
|
||||||
|
createForm.resetFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
void loadOptions();
|
||||||
|
createForm.setFieldsValue({
|
||||||
|
envPhotoUrls: ['', '', ''],
|
||||||
|
settlementRate: 60,
|
||||||
|
});
|
||||||
|
setCreateStep(0);
|
||||||
|
setCreateError('');
|
||||||
|
setCreateOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateNext() {
|
||||||
|
const values = createForm.getFieldsValue();
|
||||||
|
if (createStep === 0) {
|
||||||
|
const msg = validateStoreCreateStep1(values);
|
||||||
|
if (msg) {
|
||||||
|
setCreateError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCreateError('');
|
||||||
|
setCreateStep((s) => s + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateSubmit() {
|
||||||
|
try {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
|
const step3Msg = validateStoreCreateStep3(values);
|
||||||
|
if (step3Msg) {
|
||||||
|
setCreateError(step3Msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
||||||
|
await request('/admin/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
partnerAccountId: values.partnerAccountId,
|
||||||
|
cityId: values.cityId,
|
||||||
|
province: values.province,
|
||||||
|
city: values.city,
|
||||||
|
name: values.name.trim(),
|
||||||
|
phone: values.phone.trim(),
|
||||||
|
district: values.district.trim(),
|
||||||
|
address: values.address.trim(),
|
||||||
|
intro: values.intro?.trim() || undefined,
|
||||||
|
coverUrl: values.coverUrl?.trim() || undefined,
|
||||||
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
|
contractUrl: values.contractUrl?.trim() || undefined,
|
||||||
|
bankAccountName: values.bankAccountName.trim(),
|
||||||
|
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||||
|
bankBranch: values.bankBranch.trim(),
|
||||||
|
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('门店已创建');
|
||||||
|
closeCreateModal();
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||||
|
const fields = e as { errorFields?: Array<{ name: string[] }> };
|
||||||
|
const first = fields.errorFields?.[0]?.name?.[0];
|
||||||
|
if (first === 'partnerAccountId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
||||||
|
setCreateStep(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<StoreRow> = [
|
||||||
|
{
|
||||||
|
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||||
|
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||||
|
},
|
||||||
|
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||||
|
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||||
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '营业状态', dataIndex: 'status', width: 90,
|
||||||
|
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '审核', dataIndex: 'auditStatus', width: 100,
|
||||||
|
render: (s, row) => {
|
||||||
|
const status = s || 'APPROVED';
|
||||||
|
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
|
||||||
|
{status === 'REJECTED' && row.rejectReason ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||||
|
{row.rejectReason}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
|
||||||
|
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||||
|
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作', width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={async () => {
|
||||||
|
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
name: d.name,
|
||||||
|
phone: d.phone,
|
||||||
|
intro: d.intro,
|
||||||
|
coverUrl: d.coverUrl,
|
||||||
|
address: d.address,
|
||||||
|
district: d.district,
|
||||||
|
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
||||||
|
});
|
||||||
|
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">共 {data?.total ?? 0} 家门店(含合伙人录入)</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<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="phone" label="电话"><Input allowClear /></Form.Item>
|
||||||
|
<Form.Item name="status" label="营业状态">
|
||||||
|
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="auditStatus" label="审核">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 110 }}
|
||||||
|
placeholder="全部"
|
||||||
|
options={Object.entries(STORE_AUDIT_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>
|
||||||
|
<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); } }} />
|
||||||
|
<Drawer title="门店详情" width={600} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={detail && (
|
||||||
|
<Space wrap>
|
||||||
|
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={auditing}
|
||||||
|
onClick={async () => {
|
||||||
|
setAuditing(true);
|
||||||
|
try {
|
||||||
|
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}/audit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ approved: true, remark: '审核通过' }),
|
||||||
|
});
|
||||||
|
message.success('已通过审核,合伙人可开门营业');
|
||||||
|
setDetail({ ...detail, ...updated, auditStatus: 'APPROVED', rejectReason: null });
|
||||||
|
void reload();
|
||||||
|
} finally {
|
||||||
|
setAuditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
loading={auditing}
|
||||||
|
onClick={() => {
|
||||||
|
setRejectReason('');
|
||||||
|
setRejectOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Select defaultValue={String(detail.status)} style={{ width: 120 }}
|
||||||
|
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
onChange={async (status) => {
|
||||||
|
await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||||
|
message.success('状态已更新');
|
||||||
|
setDetail({ ...detail, status });
|
||||||
|
void reload();
|
||||||
|
}} />
|
||||||
|
<Button type="primary" onClick={async () => {
|
||||||
|
const v = await editForm.validateFields();
|
||||||
|
const payload = {
|
||||||
|
...v,
|
||||||
|
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
|
||||||
|
};
|
||||||
|
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
|
message.success('已保存');
|
||||||
|
setDetail({ ...detail, ...v });
|
||||||
|
void reload();
|
||||||
|
}}>保存</Button>
|
||||||
|
</Space>
|
||||||
|
)}>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="审核状态">
|
||||||
|
<Tag color={
|
||||||
|
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||||
|
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
|
||||||
|
}>
|
||||||
|
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{String(detail.auditStatus) === 'REJECTED' ? (
|
||||||
|
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销结算比例">
|
||||||
|
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="操作">
|
||||||
|
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||||
|
查看商户日志
|
||||||
|
</Button>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{detail.coverUrl ? (
|
||||||
|
<Descriptions.Item label="封面">
|
||||||
|
<Image src={String(detail.coverUrl)} width={120} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||||
|
<Descriptions.Item label="审核记录">
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
|
||||||
|
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
|
||||||
|
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
|
||||||
|
</Typography.Text>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
</Descriptions>
|
||||||
|
<Form form={editForm} 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="coverUrl" label="封面图">
|
||||||
|
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
|
||||||
|
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||||
|
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
||||||
|
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
<Modal
|
||||||
|
title="新建门店"
|
||||||
|
open={createOpen}
|
||||||
|
width={640}
|
||||||
|
onCancel={closeCreateModal}
|
||||||
|
destroyOnClose
|
||||||
|
footer={(
|
||||||
|
<Space>
|
||||||
|
{createStep > 0 && <Button onClick={() => { setCreateError(''); setCreateStep((s) => s - 1); }}>上一步</Button>}
|
||||||
|
{createStep < 2 ? (
|
||||||
|
<Button type="primary" onClick={() => void handleCreateNext()}>下一步</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" onClick={() => void handleCreateSubmit()}>提交</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Steps current={createStep} items={CREATE_STEPS} style={{ marginBottom: 24 }} />
|
||||||
|
{createError && (
|
||||||
|
<Alert type="error" message={createError} showIcon style={{ marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
<Form form={createForm} layout="vertical" preserve>
|
||||||
|
<div style={{ display: createStep === 0 ? 'block' : 'none' }}>
|
||||||
|
<Form.Item name="partnerAccountId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
loading={optionsLoading}
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
|
||||||
|
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||||
|
onChange={(partnerAccountId) => {
|
||||||
|
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
|
||||||
|
if (codes?.length === 3) bindRegionSelection(codes, partnerAccountId);
|
||||||
|
else createForm.setFieldValue('cityId', undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="regionCodes"
|
||||||
|
label="所在地区"
|
||||||
|
rules={[{ required: true, message: '请选择省 / 市 / 区县' }]}
|
||||||
|
extra={regionBindingHint ? (
|
||||||
|
<Typography.Text type={selectedCityId ? 'secondary' : 'warning'}>
|
||||||
|
{regionBindingHint}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
>
|
||||||
|
<ChinaRegionCascader onChange={(codes) => bindRegionSelection(codes)} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" hidden rules={[{ required: true, message: '请选择所在地区以匹配开城城市' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="province" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="city" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="district" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="districtCode" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||||
|
<Input placeholder="请输入门店名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
||||||
|
<Input placeholder="11位手机号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="intro" label="门店简介">
|
||||||
|
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
preV1 照片上传为选填,可直接下一步(与合伙人端一致)。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form.Item name="coverUrl" label="门头照">
|
||||||
|
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||||
|
</Form.Item>
|
||||||
|
<Typography.Text strong>环境照片</Typography.Text>
|
||||||
|
<Form.List name="envPhotoUrls">
|
||||||
|
{(fields) => (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
|
||||||
|
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
<Form.Item name="contractUrl" label="签约合同">
|
||||||
|
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
||||||
|
<Form.Item name="bankAccountName" label="户主姓名" rules={[{ required: true, message: '请填写户主姓名' }]}>
|
||||||
|
<Input placeholder="银行卡实名姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankAccountNo" label="银行卡号" rules={[{ required: true, message: '请填写银行卡号' }]}>
|
||||||
|
<Input placeholder="16~19位银行卡号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}>
|
||||||
|
<Input placeholder="例如:中国工商银行洛阳分行" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
|
||||||
|
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||||
|
</Form.Item>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="请确保银行卡信息准确,以免影响门店餐费结算。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="驳回门店审核"
|
||||||
|
open={rejectOpen}
|
||||||
|
okText="确认驳回"
|
||||||
|
okButtonProps={{ danger: true, loading: auditing, disabled: !rejectReason.trim() }}
|
||||||
|
onCancel={() => setRejectOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!detail || !rejectReason.trim()) return;
|
||||||
|
setAuditing(true);
|
||||||
|
try {
|
||||||
|
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}/audit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ approved: false, remark: rejectReason.trim() }),
|
||||||
|
});
|
||||||
|
message.success('已驳回,原因已同步合伙人端');
|
||||||
|
setDetail({ ...detail, ...updated, auditStatus: 'REJECTED', rejectReason: rejectReason.trim() });
|
||||||
|
setRejectOpen(false);
|
||||||
|
void reload();
|
||||||
|
} finally {
|
||||||
|
setAuditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary">驳回原因将展示给合伙人,请说明需修改的内容。</Typography.Paragraph>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
maxLength={200}
|
||||||
|
showCount
|
||||||
|
placeholder="请填写驳回原因"
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
Alert,
|
||||||
|
|
||||||
|
Button,
|
||||||
|
|
||||||
|
Card,
|
||||||
|
|
||||||
|
Collapse,
|
||||||
|
|
||||||
|
Form,
|
||||||
|
|
||||||
|
Input,
|
||||||
|
|
||||||
|
InputNumber,
|
||||||
|
|
||||||
|
Space,
|
||||||
|
|
||||||
|
Switch,
|
||||||
|
|
||||||
|
Table,
|
||||||
|
|
||||||
|
Tag,
|
||||||
|
|
||||||
|
Typography,
|
||||||
|
|
||||||
|
message,
|
||||||
|
|
||||||
|
} from 'antd';
|
||||||
|
|
||||||
|
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
|
||||||
|
最近 Mock 验证码(写入数据库,最新 50 条)
|
||||||
|
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
<Table<MockSmsCodeItem>
|
||||||
|
|
||||||
|
size="small"
|
||||||
|
|
||||||
|
rowKey="id"
|
||||||
|
|
||||||
|
loading={loading}
|
||||||
|
|
||||||
|
pagination={false}
|
||||||
|
|
||||||
|
scroll={{ y: 240 }}
|
||||||
|
|
||||||
|
locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
|
||||||
|
|
||||||
|
columns={[
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
title: '时间',
|
||||||
|
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
|
||||||
|
width: 168,
|
||||||
|
|
||||||
|
render: (v: string) => new Date(v).toLocaleString(),
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||||
|
|
||||||
|
{ title: '场景', dataIndex: 'scene', width: 160 },
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
title: '验证码',
|
||||||
|
|
||||||
|
dataIndex: 'code',
|
||||||
|
|
||||||
|
width: 88,
|
||||||
|
|
||||||
|
render: (code: string) => (
|
||||||
|
|
||||||
|
<Typography.Text copyable strong>
|
||||||
|
|
||||||
|
{code}
|
||||||
|
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
),
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
]}
|
||||||
|
|
||||||
|
dataSource={codes}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function renderField(
|
||||||
|
|
||||||
|
field: SystemConfigFieldMeta,
|
||||||
|
|
||||||
|
configuredSecrets: string[],
|
||||||
|
|
||||||
|
extra?: ReactNode,
|
||||||
|
|
||||||
|
) {
|
||||||
|
|
||||||
|
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
|
||||||
|
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div key={field.key}>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
|
||||||
|
name={field.key}
|
||||||
|
|
||||||
|
label={
|
||||||
|
|
||||||
|
<Space size={4}>
|
||||||
|
|
||||||
|
<span>{field.label}</span>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||||
|
|
||||||
|
{field.key}
|
||||||
|
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||||
|
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip={field.description}
|
||||||
|
|
||||||
|
valuePropName="checked"
|
||||||
|
|
||||||
|
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
|
||||||
|
|
||||||
|
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<Switch />
|
||||||
|
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{extra}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const input =
|
||||||
|
|
||||||
|
field.type === 'textarea' ? (
|
||||||
|
|
||||||
|
<TextArea rows={3} placeholder={field.placeholder} />
|
||||||
|
|
||||||
|
) : field.type === 'number' ? (
|
||||||
|
|
||||||
|
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
|
||||||
|
|
||||||
|
) : field.secret ? (
|
||||||
|
|
||||||
|
<Input.Password
|
||||||
|
|
||||||
|
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
|
||||||
|
|
||||||
|
autoComplete="new-password"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
|
<Input placeholder={field.placeholder} />
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
|
||||||
|
key={field.key}
|
||||||
|
|
||||||
|
name={field.key}
|
||||||
|
|
||||||
|
label={
|
||||||
|
|
||||||
|
<Space size={4} wrap>
|
||||||
|
|
||||||
|
<span>{field.label}</span>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||||
|
|
||||||
|
{field.key}
|
||||||
|
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||||
|
|
||||||
|
{isConfiguredSecret ? <Tag>已配置</Tag> : null}
|
||||||
|
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip={field.description}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{input}
|
||||||
|
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default function SystemSettingsPage() {
|
||||||
|
|
||||||
|
const [form] = Form.useForm<Record<string, string>>();
|
||||||
|
|
||||||
|
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
|
const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function load(silent = false) {
|
||||||
|
|
||||||
|
if (!silent) setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const data = await request<SystemConfigFormResponse>('/admin/system-config');
|
||||||
|
|
||||||
|
if (silent) {
|
||||||
|
// 仅刷新 Mock 验证码列表,避免轮询用服务端值覆盖未保存的表单(含 MOCK_SMS 开关)
|
||||||
|
setMeta((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
|
||||||
|
: data,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeta(data);
|
||||||
|
|
||||||
|
form.setFieldsValue(data.values);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
if (!silent) setLoading(false);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
// 以表单开关为准展示验证码;轮询只刷列表,不回写表单
|
||||||
|
if (!mockSmsEnabled) return;
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => void load(true), 5000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
|
||||||
|
}, [mockSmsEnabled]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const collapseItems = useMemo(() => {
|
||||||
|
|
||||||
|
if (!meta) return [];
|
||||||
|
|
||||||
|
return meta.groups.map((group) => ({
|
||||||
|
|
||||||
|
key: group.key,
|
||||||
|
|
||||||
|
label: group.label,
|
||||||
|
|
||||||
|
children: (
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 720 }}>
|
||||||
|
|
||||||
|
{meta.fields
|
||||||
|
|
||||||
|
.filter((f) => f.group === group.key)
|
||||||
|
|
||||||
|
.map((f) =>
|
||||||
|
|
||||||
|
renderField(
|
||||||
|
|
||||||
|
f,
|
||||||
|
|
||||||
|
meta.configuredSecrets,
|
||||||
|
|
||||||
|
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||||
|
|
||||||
|
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||||
|
|
||||||
|
) : undefined,
|
||||||
|
|
||||||
|
),
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
),
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
}, [meta, mockSmsEnabled, loading]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
|
||||||
|
const values = await form.validateFields();
|
||||||
|
|
||||||
|
const payload: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
|
||||||
|
payload[k] = v === undefined || v === null ? '' : String(v);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
|
||||||
|
|
||||||
|
'/admin/system-config',
|
||||||
|
|
||||||
|
{ method: 'PUT', body: JSON.stringify({ values: payload }) },
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
message.success(`已保存 ${res.updatedKeys.length} 项`);
|
||||||
|
|
||||||
|
if (res.requiresRestartKeys.length) {
|
||||||
|
|
||||||
|
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
await load();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
setSaving(false);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function onSyncEnv() {
|
||||||
|
|
||||||
|
setSyncing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const res = await request<{ message: string; envFilePath: string }>(
|
||||||
|
|
||||||
|
'/admin/system-config/sync-env',
|
||||||
|
|
||||||
|
{ method: 'POST' },
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
message.success(res.message || '已同步到 env 文件');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
message.error(e instanceof Error ? e.message : '同步失败');
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
setSyncing(false);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function onImportEnv() {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const res = await request<{ imported: number }>('/admin/system-config/import-env', {
|
||||||
|
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success(`已从当前进程环境导入 ${res.imported} 项`);
|
||||||
|
|
||||||
|
await load();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
message.error(e instanceof Error ? e.message : '导入失败');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
|
||||||
|
系统设置
|
||||||
|
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
|
||||||
|
|
||||||
|
配置存于 <code>system_config</code> 表;保存后写入进程环境。可同步到{' '}
|
||||||
|
|
||||||
|
<code>{meta?.envFilePath ?? '.env'}</code> 以便部署持久化。
|
||||||
|
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
|
||||||
|
<Button onClick={() => void onImportEnv()}>从环境导入</Button>
|
||||||
|
|
||||||
|
<Button loading={syncing} onClick={() => void onSyncEnv()}>
|
||||||
|
|
||||||
|
同步到 env 文件
|
||||||
|
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void onSave()}>
|
||||||
|
|
||||||
|
保存
|
||||||
|
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
|
||||||
|
type="info"
|
||||||
|
|
||||||
|
showIcon
|
||||||
|
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
|
||||||
|
message="生效说明"
|
||||||
|
|
||||||
|
description={
|
||||||
|
|
||||||
|
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
|
||||||
|
<Tag color="green">即时</Tag>:保存后写入 <code>process.env</code>,Mock 开关、短信模板等可立即生效。
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
|
||||||
|
<Tag color="orange">需重启</Tag>:微信/OSS 密钥等集成凭证变更后,<strong>建议重启 API 进程</strong>。
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>OSS 始终走阿里云配置;凭证缺失时上传接口将直接报错。</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Card loading={loading}>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
|
||||||
|
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{meta?.updatedAt ? (
|
||||||
|
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
||||||
|
|
||||||
|
最近更新:{new Date(meta.updatedAt).toLocaleString()}
|
||||||
|
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
scene: string;
|
||||||
|
refType: string | null;
|
||||||
|
refId: string | null;
|
||||||
|
requestUrl?: string | null;
|
||||||
|
requestBody?: Record<string, unknown> | null;
|
||||||
|
responseBody?: Record<string, unknown> | null;
|
||||||
|
externalNo?: string | null;
|
||||||
|
amount?: string | null;
|
||||||
|
status: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_OPTIONS = [
|
||||||
|
{ value: 'WECHAT_AUTH', label: 'WECHAT_AUTH' },
|
||||||
|
{ value: 'WECHAT_PAY', label: 'WECHAT_PAY' },
|
||||||
|
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' },
|
||||||
|
{ value: 'ALIYUN_OSS', label: 'ALIYUN_OSS' },
|
||||||
|
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' },
|
||||||
|
{ value: 'MOCK_SMS', label: 'MOCK_SMS' },
|
||||||
|
{ value: 'XFX', label: '小飞侠 (XFX)' },
|
||||||
|
{ value: 'LOGISTICS', label: 'LOGISTICS' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
SUCCESS: 'success',
|
||||||
|
FAILED: 'error',
|
||||||
|
PENDING: 'processing',
|
||||||
|
};
|
||||||
|
|
||||||
|
function summarizeJson(json: Record<string, unknown> | null | undefined) {
|
||||||
|
if (!json || Object.keys(json).length === 0) return '—';
|
||||||
|
const text = JSON.stringify(json);
|
||||||
|
return text.length > 60 ? `${text.slice(0, 60)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonBlock({ value }: { value: unknown }) {
|
||||||
|
if (value == null) return <span>—</span>;
|
||||||
|
return (
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12 }}>
|
||||||
|
{JSON.stringify(value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThirdPartyLogsPage() {
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
|
'/common/third-party-logs',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.provider) qs.set('provider', filters.provider);
|
||||||
|
if (filters.scene) qs.set('scene', filters.scene);
|
||||||
|
if (filters.refId) qs.set('refId', filters.refId);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||||
|
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||||
|
{
|
||||||
|
title: '关联',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? 'default'}>{v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '错误信息',
|
||||||
|
dataIndex: 'errorMessage',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string | null | undefined) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '请求摘要',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => summarizeJson(r.requestBody),
|
||||||
|
},
|
||||||
|
{ title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/common/third-party-logs/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>第三方日志</Typography.Title>
|
||||||
|
<Form
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="provider" label="Provider">
|
||||||
|
<Select allowClear placeholder="全部" style={{ width: 160 }} options={PROVIDER_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="scene" label="场景">
|
||||||
|
<Input allowClear placeholder="JSSDK_CONFIG / LOGIN" style={{ width: 160 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="refId" label="关联ID">
|
||||||
|
<Input allowClear style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer title="第三方日志详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={STATUS_COLOR[String(detail.status)] ?? 'default'}>{String(detail.status)}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联">
|
||||||
|
{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="金额">{detail.amount != null ? String(detail.amount) : '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="请求 URL">{String(detail.requestUrl ?? '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="错误信息">
|
||||||
|
{detail.errorMessage ? (
|
||||||
|
<Typography.Text type="danger" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{String(detail.errorMessage)}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="请求体">
|
||||||
|
<JsonBlock value={detail.requestBody} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="响应体">
|
||||||
|
<JsonBlock value={detail.responseBody} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
ticketNo: string;
|
||||||
|
ticketType: string;
|
||||||
|
status: string;
|
||||||
|
refType: string;
|
||||||
|
refId: string;
|
||||||
|
remark?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TicketsPage() {
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/tickets',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
async function approve(id: string) {
|
||||||
|
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
||||||
|
message.success('已审批通过');
|
||||||
|
reload();
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(id: string) {
|
||||||
|
await request(`/admin/tickets/${id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ remark: '驳回' }),
|
||||||
|
});
|
||||||
|
message.success('已驳回');
|
||||||
|
reload();
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||||
|
{ title: '类型', dataIndex: 'ticketType', width: 100 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||||
|
{ title: '备注', dataIndex: 'remark', 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/tickets/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}>详情</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>工单中心</Typography.Title>
|
||||||
|
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
|
<Form.Item name="ticketType" label="类型">
|
||||||
|
<Select allowClear style={{ width: 120 }} options={[
|
||||||
|
{ value: 'REFUND', label: '退款' },
|
||||||
|
{ value: 'RESHIPMENT', label: '补发' },
|
||||||
|
{ value: 'ALERT', label: '异常' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">筛选</Button>
|
||||||
|
</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={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||||
|
<>
|
||||||
|
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>通过</Button>
|
||||||
|
<Button danger onClick={() => reject(String(detail.id))}>驳回</Button>
|
||||||
|
</>
|
||||||
|
) : null}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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 { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, type UserLogCategory } from '../lib/user-log';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
userNo: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
category: UserLogCategory | null;
|
||||||
|
eventName: string;
|
||||||
|
clientApp: string | null;
|
||||||
|
refType: string | null;
|
||||||
|
refId: string | null;
|
||||||
|
extraJson: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = USER_LOG_CATEGORY_LABELS;
|
||||||
|
|
||||||
|
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 UserLogsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||||
|
userId: searchParams.get('userId') ?? '',
|
||||||
|
phone: searchParams.get('phone') ?? '',
|
||||||
|
userNo: searchParams.get('userNo') ?? '',
|
||||||
|
eventName: searchParams.get('eventName') ?? '',
|
||||||
|
}));
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/logs/users',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.userId) qs.set('userId', filters.userId);
|
||||||
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
|
if (filters.userNo) qs.set('userNo', filters.userNo);
|
||||||
|
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) => (
|
||||||
|
<AdminCellLine
|
||||||
|
primary={r.nickname || r.userNo}
|
||||||
|
secondary={r.phone || r.userId}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类', dataIndex: 'category', width: 100,
|
||||||
|
render: (v: UserLogCategory | null, r) => (
|
||||||
|
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
||||||
|
{
|
||||||
|
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/users/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}>详情</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>用户日志</Typography.Title>
|
||||||
|
<Segmented
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
|
value={category}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCategory(String(v));
|
||||||
|
setPage(1);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (v) next.set('category', String(v));
|
||||||
|
else next.delete('category');
|
||||||
|
setSearchParams(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
for (const key of ['userId', 'phone', 'userNo', 'eventName'] as const) {
|
||||||
|
if (v[key]) next.set(key, v[key]);
|
||||||
|
else next.delete(key);
|
||||||
|
}
|
||||||
|
setSearchParams(next);
|
||||||
|
}}>
|
||||||
|
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||||
|
<Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="pay_success" /></Form.Item>
|
||||||
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table rowKey="id" 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); } }} />
|
||||||
|
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">{CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="参数">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
|
||||||
|
import { request, type AdminUserRow, type Paginated } from '../lib/api';
|
||||||
|
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
type UserOrderRow = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
payAmount: number;
|
||||||
|
payStatus?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserDetail = AdminUserRow & {
|
||||||
|
wxUnionId?: string | null;
|
||||||
|
cityPref?: Record<string, unknown> | null;
|
||||||
|
mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null;
|
||||||
|
sourcePromo?: { id: string; code: string; name: string } | null;
|
||||||
|
orders?: UserOrderRow[];
|
||||||
|
mergedFromCount?: number;
|
||||||
|
addressCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchDeletePreviewItem = {
|
||||||
|
id: string;
|
||||||
|
userNo: string;
|
||||||
|
nickname: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
hasRisk: boolean;
|
||||||
|
unfinishedOrders: UserOrderRow[];
|
||||||
|
redeemRecords: Array<{
|
||||||
|
id: string;
|
||||||
|
redeemNo: string;
|
||||||
|
amount: number;
|
||||||
|
createdAt: string;
|
||||||
|
payoutStatus: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchDeletePreview = {
|
||||||
|
items: BatchDeletePreviewItem[];
|
||||||
|
hasRisk: boolean;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [detail, setDetail] = useState<UserDetail | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState('');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||||
|
const [batchDeleteStep, setBatchDeleteStep] = useState<1 | 2>(1);
|
||||||
|
const [batchPreview, setBatchPreview] = useState<BatchDeletePreview | null>(null);
|
||||||
|
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
|
||||||
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
|
const [batchRiskAck, setBatchRiskAck] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
|
if (values.phone) qs.set('phone', values.phone);
|
||||||
|
if (values.userNo) qs.set('userNo', values.userNo);
|
||||||
|
if (values.deviceKey) qs.set('deviceKey', values.deviceKey);
|
||||||
|
if (values.phoneVerified !== undefined && values.phoneVerified !== '') {
|
||||||
|
qs.set('phoneVerified', values.phoneVerified);
|
||||||
|
}
|
||||||
|
if (values.status !== undefined && values.status !== '') {
|
||||||
|
qs.set('status', String(values.status));
|
||||||
|
}
|
||||||
|
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||||
|
setData(res);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [form, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
|
||||||
|
if (openUserId) {
|
||||||
|
void openDetail(openUserId);
|
||||||
|
navigate(location.pathname, { replace: true, state: null });
|
||||||
|
}
|
||||||
|
}, [location.state, location.pathname, navigate]);
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
const res = await request<UserDetail>(`/admin/users/${id}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal() {
|
||||||
|
setDeleteConfirm('');
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!detail) return;
|
||||||
|
if (deleteConfirm !== detail.userNo) {
|
||||||
|
message.error('请输入正确的用户编号以确认删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/users/${detail.id}`, { method: 'DELETE' });
|
||||||
|
message.success('用户已删除(行为日志已保留)');
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
setSelectedRowKeys((keys) => keys.filter((k) => k !== detail.id));
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBatchDeleteModal() {
|
||||||
|
if (!selectedRowKeys.length) return;
|
||||||
|
setBatchDeleteStep(1);
|
||||||
|
setBatchRiskAck(false);
|
||||||
|
setBatchPreview(null);
|
||||||
|
setBatchDeleteOpen(true);
|
||||||
|
setBatchPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<BatchDeletePreview>('/admin/users/batch-delete/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: selectedRowKeys }),
|
||||||
|
});
|
||||||
|
setBatchPreview(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '预检失败');
|
||||||
|
setBatchDeleteOpen(false);
|
||||||
|
} finally {
|
||||||
|
setBatchPreviewLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBatchDelete(confirmRisk: boolean) {
|
||||||
|
if (!selectedRowKeys.length) return;
|
||||||
|
setBatchDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ deleted: number; message: string }>('/admin/users/batch-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: selectedRowKeys, confirmRisk }),
|
||||||
|
});
|
||||||
|
message.success(res.message || `已删除 ${res.deleted} 名用户`);
|
||||||
|
setBatchDeleteOpen(false);
|
||||||
|
setBatchPreview(null);
|
||||||
|
if (detail && selectedRowKeys.includes(detail.id)) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '批量删除失败');
|
||||||
|
} finally {
|
||||||
|
setBatchDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchDeleteOk() {
|
||||||
|
if (!batchPreview) return;
|
||||||
|
if (batchPreview.hasRisk && batchDeleteStep === 1) {
|
||||||
|
setBatchDeleteStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void confirmBatchDelete(batchPreview.hasRisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderColumns: ColumnsType<UserOrderRow> = [
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: ColumnsType<AdminUserRow> = [
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||||
|
{
|
||||||
|
title: '手机',
|
||||||
|
dataIndex: 'phone',
|
||||||
|
width: 120,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '验手机',
|
||||||
|
dataIndex: 'phoneVerifiedAt',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => (v ? <Tag color="green">已验证</Tag> : <Tag color="orange">访客</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '微信',
|
||||||
|
dataIndex: 'wechatVerified',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => (v ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源类型',
|
||||||
|
dataIndex: 'sourceType',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Tag color={v === 'PROMO_CODE' ? 'blue' : v === 'ORGANIC' ? 'default' : 'purple'}>
|
||||||
|
{USER_SOURCE_TYPE_LABELS[v as UserSourceType] || v}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源 ID',
|
||||||
|
dataIndex: 'sourceRefId',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v, row) => {
|
||||||
|
if (!v) return '—';
|
||||||
|
if (row.sourceType === 'PROMO_CODE') {
|
||||||
|
return (
|
||||||
|
<Link to={`/promo-codes/${v}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{v}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源标签',
|
||||||
|
dataIndex: 'sourceLabel',
|
||||||
|
width: 120,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'deviceKey',
|
||||||
|
dataIndex: 'deviceKey',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '合并',
|
||||||
|
dataIndex: 'mergedIntoUserId',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
|
{
|
||||||
|
title: '注册时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 170,
|
||||||
|
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" onClick={() => navigate(`/logs/users?userId=${row.id}`)}>
|
||||||
|
查看日志
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
onClick={() => void openBatchDeleteModal()}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
|
<Form.Item name="phone" label="手机号">
|
||||||
|
<Input placeholder="模糊搜索" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="userNo" label="用户编号">
|
||||||
|
<Input placeholder="DK..." allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="deviceKey" label="deviceKey">
|
||||||
|
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phoneVerified" label="验手机">
|
||||||
|
<Select allowClear style={{ width: 100 }} options={[
|
||||||
|
{ value: '1', label: '已验证' },
|
||||||
|
{ value: '0', label: '访客' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select allowClear style={{ width: 90 }} options={[
|
||||||
|
{ value: 1, label: '正常' },
|
||||||
|
{ value: 0, label: '停用' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1500 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
|
}}
|
||||||
|
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 danger onClick={openDeleteModal}>删除用户</Button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="验手机时间">
|
||||||
|
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="微信验证">
|
||||||
|
{detail.wechatVerified ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源类型">
|
||||||
|
<Tag color={detail.sourceType === 'PROMO_CODE' ? 'blue' : 'default'}>
|
||||||
|
{USER_SOURCE_TYPE_LABELS[detail.sourceType as UserSourceType] || detail.sourceType}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源 ID">
|
||||||
|
{detail.sourceRefId ? (
|
||||||
|
detail.sourceType === 'PROMO_CODE' ? (
|
||||||
|
<Link to={`/promo-codes/${detail.sourceRefId}`}>{detail.sourceRefId}</Link>
|
||||||
|
) : (
|
||||||
|
detail.sourceRefId
|
||||||
|
)
|
||||||
|
) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item>
|
||||||
|
{detail.sourcePromo && (
|
||||||
|
<Descriptions.Item label="推广码">
|
||||||
|
<Link to={`/promo-codes/${detail.sourcePromo.id}`}>
|
||||||
|
{detail.sourcePromo.name}({detail.sourcePromo.code})
|
||||||
|
</Link>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="wxOpenId">{detail.wxOpenId || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="wxUnionId">{detail.wxUnionId || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="合并至">
|
||||||
|
{detail.mergedInto
|
||||||
|
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="注册时间">
|
||||||
|
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
|
全部订单({detail.orders?.length ?? 0})
|
||||||
|
</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 520, y: 240 }}
|
||||||
|
dataSource={detail.orders ?? []}
|
||||||
|
columns={orderColumns}
|
||||||
|
locale={{ emptyText: '暂无订单' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
onClick={() => navigate(`/logs/users?userId=${detail.id}`)}
|
||||||
|
>
|
||||||
|
查看用户日志
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="确认删除用户"
|
||||||
|
open={deleteOpen}
|
||||||
|
okText="确认删除"
|
||||||
|
okButtonProps={{
|
||||||
|
danger: true,
|
||||||
|
disabled: !detail || deleteConfirm !== detail.userNo,
|
||||||
|
loading: deleting,
|
||||||
|
}}
|
||||||
|
onOk={() => void confirmDelete()}
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="此操作不可恢复"
|
||||||
|
description={(
|
||||||
|
<>
|
||||||
|
将删除用户 <strong>{detail.userNo}</strong> 及其地址、订单、权益券、核销记录等业务数据。
|
||||||
|
<br />
|
||||||
|
用户行为日志(埋点)与第三方调用日志将<strong>保留</strong>,不随用户删除。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Typography.Text strong>关联订单({detail.orders?.length ?? 0} 笔)</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8, marginBottom: 16 }}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 520, y: 200 }}
|
||||||
|
dataSource={detail.orders ?? []}
|
||||||
|
columns={orderColumns}
|
||||||
|
locale={{ emptyText: '无订单' }}
|
||||||
|
/>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
请输入用户编号 <Typography.Text code>{detail.userNo}</Typography.Text> 以确认删除:
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Input
|
||||||
|
value={deleteConfirm}
|
||||||
|
placeholder={detail.userNo}
|
||||||
|
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={batchDeleteStep === 1 ? `确认批量删除(${selectedRowKeys.length} 人)` : '二次确认:删除关联业务数据'}
|
||||||
|
open={batchDeleteOpen}
|
||||||
|
okText={batchPreview?.hasRisk && batchDeleteStep === 1 ? '下一步' : '确认删除'}
|
||||||
|
okButtonProps={{
|
||||||
|
danger: batchDeleteStep === 2 || !batchPreview?.hasRisk,
|
||||||
|
loading: batchPreviewLoading || batchDeleting,
|
||||||
|
disabled: batchDeleteStep === 2 && !batchRiskAck,
|
||||||
|
}}
|
||||||
|
cancelText={batchDeleteStep === 2 ? '上一步' : '取消'}
|
||||||
|
onOk={() => handleBatchDeleteOk()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (batchDeleteStep === 2) {
|
||||||
|
setBatchDeleteStep(1);
|
||||||
|
setBatchRiskAck(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
width={800}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{batchPreviewLoading && (
|
||||||
|
<Typography.Text type="secondary">正在检查关联订单与核销记录…</Typography.Text>
|
||||||
|
)}
|
||||||
|
{!batchPreviewLoading && batchPreview && batchDeleteStep === 1 && (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type={batchPreview.hasRisk ? 'warning' : 'error'}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={batchPreview.hasRisk ? '部分用户存在未完成订单或核销记录' : '此操作不可恢复'}
|
||||||
|
description={batchPreview.hasRisk
|
||||||
|
? '标有「需关注」的用户名下有未完成订单和/或核销记录。继续后将进入二次确认,确认后将一并删除相关订单、核销记录及权益等业务数据。用户行为日志将保留。'
|
||||||
|
: `将删除 ${batchPreview.total} 名用户及其地址、订单、权益券等业务数据。用户行为日志将保留。`}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 640, y: 320 }}
|
||||||
|
dataSource={batchPreview.items}
|
||||||
|
expandable={{
|
||||||
|
rowExpandable: (row) => row.hasRisk,
|
||||||
|
expandedRowRender: (row) => (
|
||||||
|
<div style={{ padding: '0 8px 8px' }}>
|
||||||
|
{row.unfinishedOrders.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Text strong>未完成订单({row.unfinishedOrders.length})</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8, marginBottom: 12 }}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={row.unfinishedOrders}
|
||||||
|
columns={orderColumns}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{row.redeemRecords.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Text strong>核销记录({row.redeemRecords.length})</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={row.redeemRecords}
|
||||||
|
columns={[
|
||||||
|
{ title: '核销单号', dataIndex: 'redeemNo', width: 160 },
|
||||||
|
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{
|
||||||
|
title: '打款状态',
|
||||||
|
dataIndex: 'payoutStatus',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => (v === 'PENDING' ? <Tag color="orange">待打款</Tag> : v === 'PAID' ? <Tag color="green">已打款</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '风险',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) => (row.hasRisk ? (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
<Tag color="warning">需关注</Tag>
|
||||||
|
{row.unfinishedOrders.length > 0 && (
|
||||||
|
<Tag color="orange">未完成订单 {row.unfinishedOrders.length}</Tag>
|
||||||
|
)}
|
||||||
|
{row.redeemRecords.length > 0 && (
|
||||||
|
<Tag color="red">核销 {row.redeemRecords.length}</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Tag color="default">无关联风险</Tag>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!batchPreviewLoading && batchPreview && batchDeleteStep === 2 && (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="即将删除未完成订单与核销记录"
|
||||||
|
description={(
|
||||||
|
<>
|
||||||
|
以下 <strong>{batchPreview.items.filter((i) => i.hasRisk).length}</strong> 名用户存在未完成订单或核销记录。
|
||||||
|
确认后将<strong>永久删除</strong>这些订单、核销记录、权益券及门店打款关联数据,且不可恢复。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 200 }}
|
||||||
|
dataSource={batchPreview.items.filter((i) => i.hasRisk)}
|
||||||
|
columns={[
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
|
{ title: '未完成订单', width: 110, render: (_, row) => row.unfinishedOrders.length },
|
||||||
|
{ title: '核销记录', width: 90, render: (_, row) => row.redeemRecords.length },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Checkbox checked={batchRiskAck} onChange={(e) => setBatchRiskAck(e.target.checked)}>
|
||||||
|
我确认删除上述用户的未完成订单、核销记录及相关业务数据
|
||||||
|
</Checkbox>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
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 { request } from '../lib/api';
|
||||||
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||||
|
|
||||||
|
type Identity = {
|
||||||
|
actorType: ActorType;
|
||||||
|
actorId: string;
|
||||||
|
phone: string | null;
|
||||||
|
name: string | null;
|
||||||
|
wxOpenId: string;
|
||||||
|
wxUnionId: string | null;
|
||||||
|
phoneVerified?: boolean;
|
||||||
|
refLabel: string | null;
|
||||||
|
refId: string | null;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
status: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupRow = {
|
||||||
|
groupKey: string;
|
||||||
|
unionId: string | null;
|
||||||
|
identityCount: number;
|
||||||
|
actorTypes: ActorType[];
|
||||||
|
multiRole: boolean;
|
||||||
|
primaryPhone: string | null;
|
||||||
|
latestLoginAt: string | null;
|
||||||
|
identities: Identity[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTOR_TYPE_LABELS: Record<ActorType, string> = {
|
||||||
|
USER: 'C 端用户',
|
||||||
|
STORE: '门店账号',
|
||||||
|
PARTNER: '合伙人账号',
|
||||||
|
HQ: 'HQ 账号',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTOR_TYPE_COLORS: Record<ActorType, string> = {
|
||||||
|
USER: 'blue',
|
||||||
|
STORE: 'green',
|
||||||
|
PARTNER: 'orange',
|
||||||
|
HQ: 'purple',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderActorTags(types: ActorType[]) {
|
||||||
|
return types.map((t) => (
|
||||||
|
<Tag key={t} color={ACTOR_TYPE_COLORS[t]}>
|
||||||
|
{ACTOR_TYPE_LABELS[t]}
|
||||||
|
</Tag>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WechatBindingsPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({
|
||||||
|
actorType: '',
|
||||||
|
phone: '',
|
||||||
|
unionId: '',
|
||||||
|
openId: '',
|
||||||
|
});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<GroupRow>(
|
||||||
|
'/admin/wechat-bindings',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.actorType) qs.set('actorType', filters.actorType);
|
||||||
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
|
if (filters.unionId) qs.set('unionId', filters.unionId);
|
||||||
|
if (filters.openId) qs.set('openId', filters.openId);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<GroupRow | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue(filters);
|
||||||
|
}, [form, filters]);
|
||||||
|
|
||||||
|
async function openDetail(row: GroupRow) {
|
||||||
|
const res = await request<GroupRow>(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<GroupRow> = [
|
||||||
|
{
|
||||||
|
title: 'unionId',
|
||||||
|
dataIndex: 'unionId',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || <Tag>无 unionId</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '身份数',
|
||||||
|
dataIndex: 'identityCount',
|
||||||
|
width: 90,
|
||||||
|
render: (v, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{r.multiRole ? <Tag color="red">一人多角色</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '端类型',
|
||||||
|
dataIndex: 'actorTypes',
|
||||||
|
width: 220,
|
||||||
|
render: (types: ActorType[]) => renderActorTags(types),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '手机号',
|
||||||
|
dataIndex: 'primaryPhone',
|
||||||
|
width: 140,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '身份摘要',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => (
|
||||||
|
<AdminCellLine
|
||||||
|
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
|
||||||
|
secondary={r.identities
|
||||||
|
.map((i) => i.refLabel || i.name || i.phone)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近登录',
|
||||||
|
dataIndex: 'latestLoginAt',
|
||||||
|
width: 160,
|
||||||
|
render: fmtTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const identityColumns: ColumnsType<Identity> = [
|
||||||
|
{
|
||||||
|
title: '端类型',
|
||||||
|
dataIndex: 'actorType',
|
||||||
|
width: 120,
|
||||||
|
render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账号',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => (
|
||||||
|
<AdminCellLine
|
||||||
|
primary={r.name || '—'}
|
||||||
|
secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '归属',
|
||||||
|
dataIndex: 'refLabel',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'wxOpenId',
|
||||||
|
dataIndex: 'wxOpenId',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '手机验证',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.actorType === 'USER' ? (
|
||||||
|
r.phoneVerified ? <Tag color="blue">已验证</Tag> : <Tag>未验证</Tag>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近登录',
|
||||||
|
dataIndex: 'lastLoginAt',
|
||||||
|
width: 160,
|
||||||
|
render: fmtTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => String(v),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>微信绑定总览</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(values) => {
|
||||||
|
setPage(1);
|
||||||
|
setFilters({
|
||||||
|
actorType: values.actorType ?? '',
|
||||||
|
phone: values.phone?.trim() ?? '',
|
||||||
|
unionId: values.unionId?.trim() ?? '',
|
||||||
|
openId: values.openId?.trim() ?? '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="actorType" label="端类型">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'USER', label: 'C 端用户' },
|
||||||
|
{ value: 'STORE', label: '门店账号' },
|
||||||
|
{ value: 'PARTNER', label: '合伙人账号' },
|
||||||
|
{ value: 'HQ', label: 'HQ 账号' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号">
|
||||||
|
<Input allowClear placeholder="模糊匹配" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="unionId" label="unionId">
|
||||||
|
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="openId" label="openId">
|
||||||
|
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
setFilters({ actorType: '', phone: '', unionId: '', openId: '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void reload()}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table<GroupRow>
|
||||||
|
rowKey="groupKey"
|
||||||
|
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={960}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
>
|
||||||
|
{detail ? (
|
||||||
|
<>
|
||||||
|
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="groupKey">{detail.groupKey}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="unionId">{detail.unionId || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="身份数">{detail.identityCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="端类型">{renderActorTags(detail.actorTypes)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="一人多角色">
|
||||||
|
{detail.multiRole ? <Tag color="red">是</Tag> : <Tag>否</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最近登录">{fmtTime(detail.latestLoginAt)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Table<Identity>
|
||||||
|
rowKey={(r) => `${r.actorType}-${r.actorId}`}
|
||||||
|
size="small"
|
||||||
|
columns={identityColumns}
|
||||||
|
dataSource={detail.identities}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DatePicker,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import { WINERY_SETTLEMENT_RATE } 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 Row = {
|
||||||
|
orderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
deliveryType: string;
|
||||||
|
cityName?: string;
|
||||||
|
receiverCity?: string;
|
||||||
|
payAmount: number;
|
||||||
|
wineryRate: number;
|
||||||
|
wineryAmount: number;
|
||||||
|
paidAt: string;
|
||||||
|
productName?: string;
|
||||||
|
quantity?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DELIVERY_LABELS: Record<string, string> = {
|
||||||
|
LOCAL: '同城',
|
||||||
|
CROSS_CITY: '跨城',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WineryBillsPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
|
'/admin/winery-bills',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.year) qs.set('year', filters.year);
|
||||||
|
if (filters.month) qs.set('month', filters.month);
|
||||||
|
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
async function exportExcel() {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.year) qs.set('year', filters.year);
|
||||||
|
if (filters.month) qs.set('month', filters.month);
|
||||||
|
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||||
|
const result = await request<{ csv: string; count: number }>(`/admin/winery-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 ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '配送类型',
|
||||||
|
dataIndex: 'deliveryType',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
|
||||||
|
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
|
||||||
|
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
|
||||||
|
{ title: '数量', dataIndex: 'quantity', width: 70 },
|
||||||
|
{
|
||||||
|
title: '酒单实付',
|
||||||
|
dataIndex: 'payAmount',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '酒厂比例',
|
||||||
|
dataIndex: 'wineryRate',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '酒厂应付',
|
||||||
|
dataIndex: 'wineryAmount',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付时间',
|
||||||
|
dataIndex: 'paidAt',
|
||||||
|
width: 170,
|
||||||
|
render: (v) => (v ? fmtTime(v) : '—'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
酒厂账单
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
T+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定)
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space size="large" wrap>
|
||||||
|
<Statistic title="酒单数" value={summary.count} />
|
||||||
|
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||||
|
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
|
||||||
|
setFilters({
|
||||||
|
year: v.month ? String(v.month.year()) : '',
|
||||||
|
month: v.month ? String(v.month.month() + 1) : '',
|
||||||
|
deliveryType: v.deliveryType || '',
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="month" label="账期月">
|
||||||
|
<DatePicker picker="month" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="deliveryType" label="配送类型">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'LOCAL', label: '同城' },
|
||||||
|
{ value: 'CROSS_CITY', 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 loading={exporting} onClick={() => void exportExcel()}>
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="orderId"
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
|
||||||
|
Tabs, Tag, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type XfxConfig = {
|
||||||
|
provider: string;
|
||||||
|
activeProvider: string;
|
||||||
|
apiUrl: string;
|
||||||
|
appId: string | null;
|
||||||
|
mchId: string | null;
|
||||||
|
mchIdMasked: string | null;
|
||||||
|
hasApiKey: boolean;
|
||||||
|
signType: string;
|
||||||
|
ready: boolean;
|
||||||
|
source?: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiResult = {
|
||||||
|
ok: boolean;
|
||||||
|
elapsedMs: number;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
provider?: string;
|
||||||
|
raw?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATE_DEFAULTS = {
|
||||||
|
outNumber: `TEST-${Date.now()}`,
|
||||||
|
fromName: '杜康仓库',
|
||||||
|
fromMobile: '13800000000',
|
||||||
|
fromAddress: '河南省郑州市金水区',
|
||||||
|
fromAddressDetail: '杜康酒业仓',
|
||||||
|
fromLng: 113.665,
|
||||||
|
fromLat: 34.757,
|
||||||
|
toName: '测试收货人',
|
||||||
|
toMobile: '13800000001',
|
||||||
|
toAddress: '河南省郑州市二七区',
|
||||||
|
toAddressDetail: '测试路 1 号',
|
||||||
|
toLng: 113.640,
|
||||||
|
toLat: 34.720,
|
||||||
|
goodsName: '杜康酒',
|
||||||
|
goodsNum: 2,
|
||||||
|
weight: 2,
|
||||||
|
payMode: '1',
|
||||||
|
remark: 'Admin 联调测试',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResultPanel({ result }: { result: ApiResult | null }) {
|
||||||
|
if (!result) return <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 8 }}>
|
||||||
|
<Tag color={result.ok ? 'green' : 'red'}>{result.ok ? '成功' : '失败'}</Tag>
|
||||||
|
<span>{result.elapsedMs} ms</span>
|
||||||
|
{result.code && <span>code: {result.code}</span>}
|
||||||
|
</Space>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||||
|
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XiaofeixiaTestPage() {
|
||||||
|
const [config, setConfig] = useState<XfxConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<ApiResult | null>(null);
|
||||||
|
|
||||||
|
const [freightForm] = Form.useForm();
|
||||||
|
const [coverageForm] = Form.useForm();
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
|
const [queryForm] = Form.useForm();
|
||||||
|
const [batchForm] = Form.useForm();
|
||||||
|
const [trackForm] = Form.useForm();
|
||||||
|
const [cancelForm] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<XfxConfig>('/admin/courier/xiaofeixia/config').then(setConfig);
|
||||||
|
createForm.setFieldsValue(CREATE_DEFAULTS);
|
||||||
|
freightForm.setFieldsValue({ weight: 2 });
|
||||||
|
coverageForm.setFieldsValue({ toAddress: '河南省郑州市二七区测试路1号' });
|
||||||
|
}, [createForm, freightForm, coverageForm]);
|
||||||
|
|
||||||
|
async function invoke(path: string, body: unknown) {
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await request<ApiResult>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setResult(res);
|
||||||
|
if (res.ok) message.success('调用成功');
|
||||||
|
else message.warning(res.error || '调用失败');
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e.message : String(e);
|
||||||
|
message.error(err);
|
||||||
|
setResult({ ok: false, elapsedMs: 0, error: err });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(text?: string) {
|
||||||
|
return (text ?? '').split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。凭证请在
|
||||||
|
<Link to="/fulfillment-providers">仓配管理</Link>
|
||||||
|
中配置;联调会优先使用仓配管理里启用的小飞侠承运商。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
{config && (
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions column={3} size="small">
|
||||||
|
<Descriptions.Item label="Provider">{config.activeProvider}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="API">{config.apiUrl}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商户号">{config.mchIdMasked || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="签名">{config.signType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="密钥">
|
||||||
|
{config.hasApiKey ? <Tag color="green">已配置</Tag> : <Tag color="red">未配置</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{config.ready ? <Tag color="green">可调用</Tag> : <Tag color="orange">配置不完整</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源" span={3}>
|
||||||
|
{config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'}
|
||||||
|
{config.hint ? ` · ${config.hint}` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{!config.ready && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
message="请先在仓配管理中注册小飞侠并填写 API 地址、商户号与 API Key"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'freight',
|
||||||
|
label: '运费预估 (100105)',
|
||||||
|
children: (
|
||||||
|
<Form form={freightForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/estimate-freight', v)}>
|
||||||
|
<Form.Item name="weight" label="重量 (kg)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coverage',
|
||||||
|
label: '配送范围 (100301)',
|
||||||
|
children: (
|
||||||
|
<Form form={coverageForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/check-coverage', v)}>
|
||||||
|
<Form.Item name="toAddress" label="收件地址" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={2} placeholder="完整收件地址" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'create',
|
||||||
|
label: '创建订单 (100101)',
|
||||||
|
children: (
|
||||||
|
<Form form={createForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/create-shipment', v)}>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text strong>寄件人</Typography.Text>
|
||||||
|
<Form.Item name="fromName" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromMobile" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromAddress" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromAddressDetail" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="fromLng" label="经度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="fromLat" label="纬度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text strong>收件人</Typography.Text>
|
||||||
|
<Form.Item name="toName" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toMobile" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toAddress" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toAddressDetail" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="toLng" label="经度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="toLat" label="纬度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={8}><Form.Item name="goodsName" label="货品"><Input /></Form.Item></Col>
|
||||||
|
<Col span={8}><Form.Item name="goodsNum" label="件数"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item></Col>
|
||||||
|
<Col span={8}><Form.Item name="weight" label="重量 kg"><InputNumber min={0.01} style={{ width: '100%' }} /></Form.Item></Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
|
||||||
|
<Select options={[
|
||||||
|
{ value: '1', label: '寄付' },
|
||||||
|
{ value: '2', label: '到付' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注"><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
<Button onClick={() => createForm.setFieldsValue({ ...CREATE_DEFAULTS, outNumber: `TEST-${Date.now()}` })}>
|
||||||
|
重置示例
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'query',
|
||||||
|
label: '查询订单 (100104)',
|
||||||
|
children: (
|
||||||
|
<Form form={queryForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-shipment', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号 number"><Input placeholder="小飞侠运单号" /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber"><Input /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'batch',
|
||||||
|
label: '批量查询 (100106)',
|
||||||
|
children: (
|
||||||
|
<Form form={batchForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/batch-get-shipments', {
|
||||||
|
trackingNumbers: parseList(v.trackingNumbersText),
|
||||||
|
outNumbers: parseList(v.outNumbersText),
|
||||||
|
})}>
|
||||||
|
<Form.Item name="trackingNumbersText" label="运单号(逗号分隔)">
|
||||||
|
<Input.TextArea rows={2} placeholder="NO001,NO002" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="outNumbersText" label="商家单号(逗号分隔)">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'track',
|
||||||
|
label: '路由查询 (100102)',
|
||||||
|
children: (
|
||||||
|
<Form form={trackForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-track', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号"><Input /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号"><Input /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cancel',
|
||||||
|
label: '取消订单 (100103)',
|
||||||
|
children: (
|
||||||
|
<Form form={cancelForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/cancel-shipment', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号"><Input /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号"><Input /></Form.Item>
|
||||||
|
<Button type="primary" danger htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card title="响应结果" size="small">
|
||||||
|
<ResultPanel result={result} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { Breadcrumb, Button, Space, Spin, Tabs, Tag, Typography } from 'antd';
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
PROMO_CODE_STATUS_LABELS,
|
||||||
|
type PromoCodeItem,
|
||||||
|
type PromoCodeStats,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../../lib/api';
|
||||||
|
|
||||||
|
export type PromoCodeDetailContext = {
|
||||||
|
detail: PromoCodeItem & { stats?: PromoCodeStats };
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PromoCodeDetailLayout() {
|
||||||
|
const { id = '' } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [detail, setDetail] = useState<(PromoCodeItem & { stats?: PromoCodeStats }) | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<PromoCodeItem & { stats?: PromoCodeStats }>(`/admin/promo-codes/${id}`);
|
||||||
|
setDetail(res);
|
||||||
|
} catch {
|
||||||
|
setDetail(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadDetail();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const tabKey = location.pathname.endsWith('/users') ? 'users' : 'overview';
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin style={{ display: 'block', margin: '80px auto' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="danger">推广码不存在或加载失败</Typography.Text>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Button onClick={() => navigate('/promo-codes')}>返回列表</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
items={[
|
||||||
|
{ title: <Link to="/promo-codes">推广码管理</Link> },
|
||||||
|
{ title: detail.name },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<Space align="center">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/promo-codes')}
|
||||||
|
style={{ marginLeft: -8 }}
|
||||||
|
/>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
{detail.name}
|
||||||
|
</Typography.Title>
|
||||||
|
<Tag color={detail.status === 'ACTIVE' ? 'green' : 'default'}>
|
||||||
|
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
<Typography.Text type="secondary" copyable={{ text: detail.code }}>
|
||||||
|
码值:{detail.code}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
activeKey={tabKey}
|
||||||
|
onChange={(key) => {
|
||||||
|
if (key === 'users') navigate(`/promo-codes/${id}/users`);
|
||||||
|
else navigate(`/promo-codes/${id}`);
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{ key: 'overview', label: '概览' },
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
label: `关联用户${detail.stats?.sourceMarkedCount != null ? ` (${detail.stats.sourceMarkedCount})` : ''}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Outlet context={{ detail, reload: loadDetail } satisfies PromoCodeDetailContext} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Descriptions,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PROMO_CODE_SCENE_LABELS,
|
||||||
|
PROMO_CODE_STATUS_LABELS,
|
||||||
|
promoConversion,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../../lib/api';
|
||||||
|
import { fmtTime } from '../../lib/constants';
|
||||||
|
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||||
|
|
||||||
|
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 PromoCodeDetailPage() {
|
||||||
|
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const stats = detail.stats;
|
||||||
|
|
||||||
|
async function handleEdit(values: Record<string, string>) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/promo-codes/${detail.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: values.name,
|
||||||
|
scene: values.scene,
|
||||||
|
ownerUserId: values.ownerUserId?.trim() || null,
|
||||||
|
remark: values.remark?.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
setEditOpen(false);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<Card title="推广二维码" size="small">
|
||||||
|
{detail.qrcodeUrl ? (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<img
|
||||||
|
src={detail.qrcodeUrl}
|
||||||
|
alt="推广二维码"
|
||||||
|
style={{ width: 200, height: 200, marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
|
||||||
|
>
|
||||||
|
下载二维码
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无二维码</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={16}>
|
||||||
|
<Card
|
||||||
|
title="基础信息"
|
||||||
|
size="small"
|
||||||
|
extra={(
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => {
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
name: detail.name,
|
||||||
|
scene: detail.scene,
|
||||||
|
remark: detail.remark,
|
||||||
|
ownerUserId: detail.ownerUser?.id,
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={detail.status === 'ACTIVE' ? '确认关闭此推广码?关闭后不可再归因' : '确认重新启用?'}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await request(`/admin/promo-codes/${detail.id}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: detail.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('状态已更新');
|
||||||
|
await reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button size="small" danger={detail.status === 'ACTIVE'}>
|
||||||
|
{detail.status === 'ACTIVE' ? '关闭' : '启用'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
|
||||||
|
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="场景">
|
||||||
|
{PROMO_CODE_SCENE_LABELS[detail.scene] || detail.scene}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="二维码 ID" span={2}>
|
||||||
|
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="落地链接" span={2}>
|
||||||
|
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="OSS 地址" span={2}>
|
||||||
|
{detail.qrcodeUrl ? (
|
||||||
|
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
|
||||||
|
{detail.qrcodeUrl}
|
||||||
|
</Typography.Text>
|
||||||
|
) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="渠道负责人">
|
||||||
|
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="转化率"
|
||||||
|
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="编辑推广码"
|
||||||
|
open={editOpen}
|
||||||
|
onCancel={() => setEditOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical" onFinish={handleEdit}>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="ownerUserId" label="关联用户 ID">
|
||||||
|
<Input placeholder="留空表示解除关联" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving} block>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user