Merge #1 into dev from dev_jacy
支付时检测是否已经微信授权 * dev_jacy: (15 commits) v3数据表更改 fix:更新skill和rules fix:需求完善 agents 和 skills 上传 fix:提交修改信息 Merge commit 'a0ee3a21cd01229a555384ad5e1f4b3ccdcdd620' into dev_jacy 微信SDK接通 oss1 web端打通oss资源上传 admin web 修改订单状态 手机号从地址栏里获取 对接微信支付 oss接通了80端口,还没有接通https,先用 微信支付 支付时检测是否已经微信授权 Signed-off-by: jacy <moonjie444@163.com> Reviewed-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/1
This commit is contained in:
@@ -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: all | pending_pay | pending_ship | pending_receive | completed
|
||||
|
||||
## Session start
|
||||
|
||||
1. Read task card if given → `dukang-task-card` skill
|
||||
2. Confirm changes stay in Owner A paths
|
||||
3. UI: `pages/user/` + `pages/ROUTE_MAP.md`
|
||||
4. API/DB: V2 manual §五/§六
|
||||
|
||||
## 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 scope note
|
||||
|
||||
- admin-web replaces HQ **mini program** for dev/demo only
|
||||
- Many HQ features are **Seed-fixed** in preV1 (cities, 4 SKUs)
|
||||
- Do not assume admin-web UI matches `pages/hq/` 1:1 — V2 will use Taro mini-hq
|
||||
|
||||
## 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. Check preV1 §6 for what's Seed vs implemented UI
|
||||
2. Product/city rules: V2 manual §二 §6
|
||||
3. Settlement: T+1 store / T+30 partner (M5+)
|
||||
|
||||
## 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 300s
|
||||
- Amount: `0 < amount ≤ min(balance, 500)`
|
||||
- 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,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 5min(无 DB 表)
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
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` 等),编码以 **当前实际目录** 为准。
|
||||
|
||||
## 文档
|
||||
|
||||
- 业务事实源:`杜康好客-V2编码手册.md`
|
||||
- preV1 裁剪:`杜康好客-preV1编码手册.md`
|
||||
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
|
||||
|
||||
**禁止**:编码时修改业务规则;需求变更只改 V2 手册。
|
||||
|
||||
## 边界(2 人团队)
|
||||
|
||||
| 负责人 | 路径 |
|
||||
|--------|------|
|
||||
| jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, `modules/{iam,trade,benefit,analytics,catalog,settlement,ops}/` |
|
||||
| 刘京尧 | `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 ≤ min(balance, **500**)
|
||||
- 订单 Tab:`all | pending_pay | pending_ship | pending_receive | completed`
|
||||
- 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 | 刘京尧 | 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): enforce 500 yuan cap
|
||||
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`、核销 ¥500 上限在此实现
|
||||
- 变更必须有单元测试
|
||||
|
||||
```typescript
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
// 同城起购 2 瓶 / 跨城 6 瓶
|
||||
// 核销上限 ¥500
|
||||
```
|
||||
|
||||
## 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 5 分钟
|
||||
- 支付流水 → `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. 确认阶段:preV1 → 读 `杜康好客-preV1编码手册.md`;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
|
||||
|
||||
**需求变更** → 只改 V2 手册,编码时不改业务规则。
|
||||
|
||||
## 编码前 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:`all | pending_pay | pending_ship | pending_receive | completed`
|
||||
|
||||
## 数据库变更
|
||||
|
||||
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;
|
||||
// 核销:0 < amount ≤ min(balance, 500)
|
||||
// 同城 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
|
||||
|
||||
```
|
||||
all | pending_pay | pending_ship | pending_receive | completed
|
||||
```
|
||||
|
||||
## 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 端订单 **5 Tab**(含 pending_ship)
|
||||
- 个人中心无会员等级(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,68 @@
|
||||
---
|
||||
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 | `杜康好客-V2编码手册.md` §二 | **做什么**(业务规则、状态、边界) |
|
||||
| 2 | `conventions.md` | **怎么协作**(模块边界、提交) |
|
||||
| 3 | V2 手册 §四 | **怎么架构**(NestJS 模块) |
|
||||
| 4 | V2 手册 §五 | **表结构**(Prisma / SQL) |
|
||||
| 5 | V2 手册 §六 | **接口契约**(四端 JWT) |
|
||||
| 6 | `pages/{user,shop,partner,hq}/` | **UI 参照** |
|
||||
| 7 | `杜康好客-preV1编码手册.md` | preV1 联调范围 |
|
||||
|
||||
## 需求变更规则
|
||||
|
||||
- **只改** `杜康好客-V2编码手册.md` 作为业务事实源
|
||||
- 编码 Agent **不得**边写代码边改业务规则
|
||||
- 改 API/表结构 → 同步手册 §五/§六 + `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,85 @@
|
||||
---
|
||||
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 命名空间:`P1-*`(preV1)与 `M*-*`(V2)并行,勿混用验收标准
|
||||
- 需求变更只改 V2 手册,不在任务实现中私改业务规则
|
||||
|
||||
## 领取流程
|
||||
|
||||
1. **解析 ID** → 确定里程碑与 OWNER
|
||||
- `P1-M2-002` → preV1 §9 + OWNER 见卡片内容
|
||||
- `M2-BE-TRD-002` → V2 §七 附录 A
|
||||
2. **读验收标准** → 手册中该 ID 的「验收」列
|
||||
3. **读依赖** → PRD §、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 | 核销 ¥500 上限 |
|
||||
| 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 无新增错误
|
||||
```
|
||||
|
||||
## 里程碑顺序
|
||||
|
||||
**preV1**:P1-M0 → P1-M1 → P1-M2 → P1-M3 → P1-M4 → P1-M5 → P1-M6
|
||||
|
||||
**V2**:M0 → M1 → … → M6 → 上线
|
||||
|
||||
除非用户指定,默认从当前里程碑**下一个未完成**任务卡开始;不确定时先问用户或跑 smoke 判断进度。
|
||||
|
||||
## 输出格式(任务完成时)
|
||||
|
||||
```markdown
|
||||
## 任务卡 {ID} 完成
|
||||
|
||||
**改动范围**:(路径列表)
|
||||
**验收**:(逐条 ✓)
|
||||
**未做/阻塞**:(如有,需其他 OWNER 配合的项)
|
||||
**建议下一步**:(下一任务卡 ID)
|
||||
```
|
||||
@@ -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,141 @@
|
||||
# 杜康好客 · AGENTS.md
|
||||
|
||||
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。
|
||||
|
||||
## 项目概览
|
||||
|
||||
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
|
||||
|
||||
| 阶段 | 手册 | 说明 |
|
||||
|------|------|------|
|
||||
| **当前 preV1** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | 三端 H5 + Mock 联调,同库同 API |
|
||||
| **目标 V2** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 四端小程序 + 真实第三方(唯一事实源) |
|
||||
|
||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
||||
|
||||
## 启动命令
|
||||
|
||||
```bash
|
||||
# 基础设施
|
||||
cd deploy && docker compose up -d
|
||||
|
||||
# 依赖与数据库
|
||||
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-prev1.mjs
|
||||
pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
|
||||
> **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
|
||||
|
||||
## 文档优先级(冲突时)
|
||||
|
||||
1. `杜康好客-V2编码手册.md` §二 — 业务规则
|
||||
2. `杜康好客-preV1编码手册.md` — preV1 裁剪(Mock / Flag)
|
||||
3. [`conventions.md`](./conventions.md) — 协作与模块边界
|
||||
4. V2 手册 §四 §五 §六 — 架构 / DB / API
|
||||
5. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照
|
||||
|
||||
## 团队与 OWNER 边界(2 人)
|
||||
|
||||
| 负责人 | Git 账号 | 职责 |
|
||||
|--------|----------|------|
|
||||
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
|
||||
| **刘京尧** | `刘京尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块 |
|
||||
|
||||
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**人员合并**如下:
|
||||
|
||||
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|
||||
|------------|--------|----------|-------------|
|
||||
| **A + C + Lead** | jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | `iam`, `trade`, `benefit`, `analytics`, `catalog`, `settlement`, `ops` |
|
||||
| **B + D** | 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` |
|
||||
|
||||
**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 |
|
||||
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
|
||||
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
|
||||
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | P1-* / M*-* |
|
||||
|
||||
## 子 Agent(按职责选用)
|
||||
|
||||
在 Cursor Agent 输入 `/` 选择:
|
||||
|
||||
| 子 Agent | 负责人 | 适用场景 |
|
||||
|----------|--------|----------|
|
||||
| `owner-a-user-trade` | jacy-dukang | C 端 H5、订单、支付、权益、埋点 |
|
||||
| `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-coding` | 实现功能、修 Bug(通用编码流程) |
|
||||
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
|
||||
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
|
||||
| `dukang-task-card` | 领取任务卡 `P1-*` / `M*-*` 并按 DoD 交付 |
|
||||
|
||||
## 快速指令
|
||||
|
||||
- 实现任务卡:`@dukang-coding 实现 P1-M2-002`
|
||||
- 需求评审:`@dukang-project 对照 pages/partner/ 评审 PRD`
|
||||
- PR 前审查:`/boundary-reviewer`
|
||||
- 原型参照:`pages/{user,shop,partner}/`(只读)
|
||||
|
||||
## 核心业务常量(不可偏离)
|
||||
|
||||
```
|
||||
权益额 = benefit_amount ?? price
|
||||
同城起购 2 瓶 / 跨城 6 瓶
|
||||
核销:0 < amount ≤ min(balance, 500);Redis 码 5 分钟
|
||||
C 端门店仅 status=OPEN
|
||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||
```
|
||||
|
||||
## 提交与 PR
|
||||
|
||||
- Conventional Commits:`feat(trade):`、`fix(redeem):`;scope = 端或模块
|
||||
- 跨模块 PR → jacy-dukang + 刘京尧 共同 Review(若涉及双方模块)
|
||||
- 改 API/表 → 同步 V2 手册 §五/§六 + `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)
|
||||
@@ -23,8 +23,8 @@ pnpm install
|
||||
cp server/dukang-api/.env.example server/dukang-api/.env
|
||||
pnpm db:generate
|
||||
cd server/dukang-api
|
||||
npx prisma db push
|
||||
pnpm prisma:seed
|
||||
npx prisma db push # v3.1 首次切换加 --accept-data-loss
|
||||
pnpm prisma:seed # seed-v31.ts
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# 杜康好客 · 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. 启动流程(每次会话)
|
||||
|
||||
1. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 §
|
||||
2. 阅读 [`skills.md`](./skills.md) 中的编码检查清单
|
||||
3. 阅读 [`conventions.md`](./conventions.md) 模块边界
|
||||
4. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ 在 preV1 §9 或 V2 §七 查验收标准
|
||||
5. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq)
|
||||
6. 一次只完成一个任务卡;完成后自验 DoD
|
||||
1. 阅读 [`AGENTS.md`](./AGENTS.md) 确认 OWNER 边界与当前阶段(preV1)
|
||||
2. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 §
|
||||
3. 启用 Skill:`@dukang-coding`(通用)或 `@dukang-prev1` / `@dukang-task-card`(按需)
|
||||
4. 按 OWNER 选择子 Agent:`/owner-a-user-trade` 等(见 AGENTS.md §子 Agent)
|
||||
5. 阅读 [`conventions.md`](./conventions.md) 模块边界
|
||||
6. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ preV1 §9 或 V2 §七
|
||||
7. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq 小程序;HQ 用 admin-web)
|
||||
8. 一次只完成一个任务卡;PR 前可用 `/boundary-reviewer` 审查跨模块违规
|
||||
|
||||
---
|
||||
|
||||
@@ -71,15 +76,13 @@
|
||||
|
||||
---
|
||||
|
||||
## 6. 模块 OWNER(写代码时只改自己的目录)
|
||||
## 6. 模块 OWNER(2 人团队)
|
||||
|
||||
| OWNER | App | Module |
|
||||
|-------|-----|--------|
|
||||
| A | mini-user | iam, trade, benefit, analytics |
|
||||
| B | mini-partner | store |
|
||||
| C | mini-hq | catalog, settlement, ops |
|
||||
| D | h5-shop | redeem |
|
||||
| Lead | packages/*, callbacks/, jobs/ | 横切 |
|
||||
| 负责人 | Git 账号 | App(preV1 → V2) | Module |
|
||||
|--------|----------|-------------------|--------|
|
||||
| Jacy | jacy-dukang | h5-user、admin-web → mini-user/mini-hq | iam, trade, benefit, analytics, catalog, settlement, ops |
|
||||
| 刘京尧 | 刘京尧 | h5-partner、h5-shop → mini-partner | store, redeem |
|
||||
| Jacy(横切) | jacy-dukang | packages/*, callbacks/, jobs/, integrations/ | 基础设施 |
|
||||
|
||||
**禁止**:Module A 直写 Module B 的 Prisma 表;apps import server 源码。
|
||||
|
||||
@@ -132,4 +135,4 @@ dukang/
|
||||
|
||||
---
|
||||
|
||||
*编码时 @ 本文件或阅读 `skills.md`;需求变更只改 `杜康好客-V2编码手册.md`。*
|
||||
*编码时 @ [`AGENTS.md`](./AGENTS.md) 或 Skill `dukang-coding`;需求变更只改 `杜康好客-V2编码手册.md`。*
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 杜康好客 · 前端 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/` |
|
||||
| `h5-shop` | 5174 | `SHOP_H5` | 刘京尧 | `pages/shop/` |
|
||||
| `h5-partner` | 5175 | `PARTNER_H5` | 刘京尧 | `pages/partner/` |
|
||||
| `admin-web` | — | (Admin JWT) | jacy-dukang | preV1 内部 HQ 替代 |
|
||||
|
||||
V2 目标:`mini-user` / `mini-partner` / `mini-hq` 替换对应 H5(除门店仍 H5)。
|
||||
|
||||
## 前端硬规则
|
||||
|
||||
- 请求基址 `/api/v1`;Header:`Authorization` + `X-Client-App`
|
||||
- 类型从 `@dukang/shared-types` import,禁止复制枚举字符串
|
||||
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||
- UI 共享组件优先 `@dukang/shared-ui`
|
||||
- C 端订单列表 **5 Tab**(含 `pending_ship`)
|
||||
|
||||
## 新页面 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 支付 → 5 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
|
||||
```
|
||||
@@ -13,6 +13,7 @@
|
||||
"@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"
|
||||
|
||||
@@ -16,6 +16,8 @@ import DeliveriesPage from './pages/DeliveriesPage';
|
||||
import HqAccountsPage from './pages/HqAccountsPage';
|
||||
import CitiesPage from './pages/CitiesPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import ProductsPage from './pages/ProductsPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -36,9 +38,11 @@ export default function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<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={<PartnersPage />} />
|
||||
<Route path="/cities" element={<CitiesPage />} />
|
||||
<Route path="/partner-accounts" element={<PartnerAccountsPage />} />
|
||||
|
||||
@@ -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,91 @@
|
||||
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);
|
||||
if (result.mock) {
|
||||
message.info('当前为 Mock OSS,已使用占位 URL');
|
||||
} else {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CarOutlined,
|
||||
SafetyOutlined,
|
||||
LogoutOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
|
||||
@@ -20,7 +21,9 @@ const { Header, Sider, Content } = Layout;
|
||||
const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||
{ key: '/products', icon: <ShoppingOutlined />, label: '商品' },
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||
{
|
||||
key: 'stores-group',
|
||||
icon: <ShopOutlined />,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { codeToText, regionData } from 'element-china-area-data';
|
||||
|
||||
export { regionData as CHINA_REGION_OPTIONS };
|
||||
|
||||
export type OpenCityRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerId?: string | null;
|
||||
partner?: { id: string };
|
||||
};
|
||||
|
||||
export type ParsedChinaRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
districtCode: string;
|
||||
};
|
||||
|
||||
/** 区县 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,
|
||||
partnerId?: string,
|
||||
): string | undefined {
|
||||
const cityCode = districtCodeToCityCode(districtCode);
|
||||
const scoped = partnerId
|
||||
? cities.filter((c) => {
|
||||
const pid = c.partnerId ?? c.partner?.id;
|
||||
return !pid || String(pid) === partnerId;
|
||||
})
|
||||
: 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[],
|
||||
partnerId?: string,
|
||||
) {
|
||||
const region = parseRegionCodes(codes);
|
||||
if (!region) return null;
|
||||
const cityId = matchOpenCityId(cities, region.districtCode, partnerId);
|
||||
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
|
||||
return {
|
||||
region,
|
||||
cityId,
|
||||
matchedCity,
|
||||
cityCode: districtCodeToCityCode(region.districtCode),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
|
||||
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
@@ -42,6 +45,45 @@ export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
|
||||
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 LEDGER_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export type StoreCreateForm = {
|
||||
partnerId: 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;
|
||||
accountPhone?: string;
|
||||
accountName?: string;
|
||||
};
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
export function validateStoreCreateStep1(
|
||||
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||
): string | null {
|
||||
if (!form.partnerId) 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,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
@@ -37,7 +37,7 @@ export default function CitiesPage() {
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>('/admin/partners?pageSize=200');
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setPartners(res.items);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,21 +10,11 @@ import {
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '已出库',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
import { ORDER_STATUS_LABELS } from '../lib/constants';
|
||||
|
||||
type OrderDetail = AdminOrderRow & {
|
||||
receiverAddress?: string;
|
||||
@@ -93,7 +83,7 @@ export default function OrdersPage() {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s) => <Tag>{STATUS_LABELS[s] || s}</Tag>,
|
||||
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '配送',
|
||||
@@ -140,7 +130,7 @@ export default function OrdersPage() {
|
||||
<Input placeholder="DK..." allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="receiverPhone" label="收货手机">
|
||||
<Input allowClear />
|
||||
@@ -171,12 +161,37 @@ export default function OrdersPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="订单详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
<Drawer
|
||||
title="订单详情"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space>
|
||||
<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="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{ORDER_STATUS_LABELS[detail.status] || detail.status}</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>
|
||||
@@ -215,7 +230,7 @@ export default function OrdersPage() {
|
||||
dataSource={detail.statusLogs}
|
||||
columns={[
|
||||
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
|
||||
{ title: '到', dataIndex: 'toStatus', render: (v) => STATUS_LABELS[v] || v },
|
||||
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
|
||||
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
@@ -46,7 +46,7 @@ export default function PartnerAccountsPage() {
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>('/admin/partners?pageSize=200');
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setPartners(res.items);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
|
||||
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;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
});
|
||||
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="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: 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 && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
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">
|
||||
<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 /></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>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/products', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}} width={520}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0 }}>
|
||||
<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="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><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="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>
|
||||
</Form>
|
||||
</Modal>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
@@ -34,7 +34,7 @@ export default function StoreAccountsPage() {
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
|
||||
async function loadStores() {
|
||||
const res = await request<Paginated<StoreOption>>('/admin/stores?pageSize=200');
|
||||
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
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;
|
||||
@@ -35,7 +36,7 @@ export default function StoreMediaPage() {
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
|
||||
async function loadStores() {
|
||||
const res = await request<Paginated<StoreOption>>('/admin/stores?pageSize=200');
|
||||
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
@@ -102,7 +103,13 @@ export default function StoreMediaPage() {
|
||||
<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 name="url" label="资源 URL" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
|
||||
{({ getFieldValue }) => (
|
||||
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
|
||||
<OssUpload bizType="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>
|
||||
@@ -116,7 +123,13 @@ export default function StoreMediaPage() {
|
||||
}}>
|
||||
<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 name="url" label="资源 URL" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
|
||||
{({ getFieldValue }) => (
|
||||
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
|
||||
<OssUpload bizType="ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Image, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
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 { STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, 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;
|
||||
@@ -24,12 +52,18 @@ type StoreRow = {
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type CityOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerId?: string | null;
|
||||
partner?: { id: string; companyName: string };
|
||||
};
|
||||
|
||||
export default function StoresPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = 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',
|
||||
@@ -45,16 +79,142 @@ export default function StoresPage() {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = 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('partnerId', createForm);
|
||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||
|
||||
function bindRegionSelection(codes: string[], partnerId?: string) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerId ?? 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() {
|
||||
const [p, c] = await Promise.all([
|
||||
request<Paginated<PartnerOption>>('/admin/partners?pageSize=200'),
|
||||
request<Paginated<CityOption>>('/admin/cities?pageSize=200'),
|
||||
]);
|
||||
setPartners(p.items);
|
||||
setCities(c.items);
|
||||
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: ['', '', ''],
|
||||
});
|
||||
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(['partnerId', '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({
|
||||
partnerId: values.partnerId,
|
||||
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(),
|
||||
accountPhone: values.accountPhone?.trim() || undefined,
|
||||
accountName: values.accountName?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
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 === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
||||
setCreateStep(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<StoreRow> = [
|
||||
@@ -90,7 +250,7 @@ export default function StoresPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadOptions(); setCreateOpen(true); }}>新建门店</Button>
|
||||
<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>
|
||||
@@ -128,16 +288,18 @@ export default function StoresPage() {
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
{detail.coverUrl && (
|
||||
{detail.coverUrl ? (
|
||||
<Descriptions.Item label="封面">
|
||||
<Image src={String(detail.coverUrl)} width={120} />
|
||||
</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="封面图 URL"><Input /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面图">
|
||||
<OssUpload bizType="COVER" 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>
|
||||
@@ -145,29 +307,120 @@ export default function StoresPage() {
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建门店" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/stores', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} />
|
||||
</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="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面图 URL"><Input /></Form.Item>
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={3} /></Form.Item>
|
||||
<Form.Item name="accountPhone" label="店长手机"><Input placeholder="默认同门店电话" /></Form.Item>
|
||||
<Form.Item name="accountName" label="店长姓名"><Input placeholder="默认同门店名" /></Form.Item>
|
||||
<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="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
loading={optionsLoading}
|
||||
optionFilterProp="label"
|
||||
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
onChange={(partnerId) => {
|
||||
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
|
||||
if (codes?.length === 3) bindRegionSelection(codes, partnerId);
|
||||
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>
|
||||
<Form.Item name="accountPhone" label="店长手机">
|
||||
<Input placeholder="默认同门店电话" />
|
||||
</Form.Item>
|
||||
<Form.Item name="accountName" label="店长姓名">
|
||||
<Input placeholder="默认同门店名" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
|
||||
<Typography.Paragraph type="secondary">
|
||||
preV1 照片上传为选填,可直接下一步(与合伙人端一致)。
|
||||
</Typography.Paragraph>
|
||||
<Form.Item name="coverUrl" label="门头照">
|
||||
<OssUpload bizType="COVER" 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="ENV" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name="contractUrl" label="签约合同">
|
||||
<OssUpload bizType="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>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="请确保银行卡信息准确,以免影响门店餐费结算。"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,9 @@ import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import CenterPage from './pages/CenterPage';
|
||||
import BillsPage from './pages/BillsPage';
|
||||
import ReshipPage from './pages/ReshipPage';
|
||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -22,6 +25,9 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/center/bills" element={<BillsPage />} />
|
||||
<Route path="/reshipments" element={<ReshipPage />} />
|
||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export type LeaderboardEntry = {
|
||||
rank: number;
|
||||
name: string;
|
||||
role: string;
|
||||
totalStores: number;
|
||||
monthStores: number;
|
||||
};
|
||||
|
||||
/** preV1:贡献榜 Mock,待 `/partner/dashboard/leaderboard` 接入后替换 */
|
||||
export const LEADERBOARD_MOCK: LeaderboardEntry[] = [
|
||||
{ rank: 1, name: '陈经理', role: '内部员工', totalStores: 45, monthStores: 8 },
|
||||
{ rank: 2, name: '李华', role: '城市合伙人', totalStores: 32, monthStores: 5 },
|
||||
{ rank: 3, name: '王拓', role: '推广员', totalStores: 18, monthStores: 3 },
|
||||
{ rank: 4, name: '赵敏', role: '内部员工', totalStores: 12, monthStores: 2 },
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
export type StoreDraftForm = {
|
||||
name: string;
|
||||
phone: string;
|
||||
district: string;
|
||||
address: string;
|
||||
intro: string;
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
};
|
||||
|
||||
export type StoreDraft = {
|
||||
step: number;
|
||||
form: StoreDraftForm;
|
||||
};
|
||||
|
||||
export const STORE_DRAFT_KEY = 'partner_store_draft_v1';
|
||||
|
||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
name: '',
|
||||
phone: '',
|
||||
district: '金水区',
|
||||
address: '',
|
||||
intro: '',
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
});
|
||||
|
||||
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
const base = defaultStoreForm();
|
||||
return {
|
||||
name: String(raw.name ?? base.name),
|
||||
phone: String(raw.phone ?? base.phone),
|
||||
district: String(raw.district ?? base.district),
|
||||
address: String(raw.address ?? base.address),
|
||||
intro: String(raw.intro ?? base.intro),
|
||||
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
|
||||
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
|
||||
bankBranch: String(raw.bankBranch ?? base.bankBranch),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadStoreDraft(): StoreDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (parsed.form && typeof parsed.form === 'object') {
|
||||
return {
|
||||
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
|
||||
form: normalizeForm(parsed.form as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
return { step: 1, form: normalizeForm(parsed) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoreDraft(draft: StoreDraft) {
|
||||
localStorage.setItem(STORE_DRAFT_KEY, JSON.stringify(draft));
|
||||
}
|
||||
|
||||
export function clearStoreDraft() {
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
}
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
export function validateStoreStep1(form: StoreDraftForm): string | null {
|
||||
if (!form.name.trim()) return '请填写门店名称';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.district.trim()) return '请填写区县';
|
||||
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 validateStoreStep3(form: StoreDraftForm): 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;
|
||||
}
|
||||
@@ -103,13 +103,34 @@ export default function CenterPage() {
|
||||
<Link to="/center/bills" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>待确认账单</span>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>财务对账</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/reports/weekly" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>数据周报</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/reshipments" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>补发处理</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/leaderboard" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>合伙人贡献榜</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,29 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function auditStatus(store: Record<string, unknown>) {
|
||||
const audits = store.audits as Array<{ status?: string }> | undefined;
|
||||
return String(audits?.[0]?.status ?? '').toUpperCase();
|
||||
}
|
||||
|
||||
function storeStatusLabel(store: Record<string, unknown>) {
|
||||
const audit = auditStatus(store);
|
||||
if (audit === 'PENDING') return '待审核';
|
||||
const s = String(store.status).toUpperCase();
|
||||
if (s === 'OPEN') return '营业中';
|
||||
if (s === 'PAUSED') return '暂时闭店';
|
||||
if (s === 'CLOSED') return '已关闭';
|
||||
return s;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request('PARTNER_H5', '/partner/dashboard').then(setDash);
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
}, [navigate]);
|
||||
|
||||
const storeCount = Number(dash?.storeCount || 0);
|
||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||
const orderCount = Number(dash?.orderCount || 0);
|
||||
const activeStores = Math.max(0, storeCount - Math.ceil(storeCount * 0.05));
|
||||
const abnormalStores = storeCount - activeStores;
|
||||
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
||||
const abnormalStores = Math.max(0, storeCount - activeStores);
|
||||
const pendingStores = useMemo(
|
||||
() => stores.filter((s) => auditStatus(s) === 'PENDING'),
|
||||
[stores],
|
||||
);
|
||||
const revenue = orderCount * 128.45;
|
||||
const profit = revenue * 0.25;
|
||||
const pendingShip = Math.ceil(orderCount * 0.04);
|
||||
const shipping = Math.ceil(orderCount * 0.12);
|
||||
const completed = Math.max(0, orderCount - pendingShip - shipping);
|
||||
const monthNew = stores.filter((s) => {
|
||||
const created = new Date(String(s.createdAt || ''));
|
||||
const now = new Date();
|
||||
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
|
||||
const recentFromDash = (dash?.recentStores as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const displayStores = stores.length > 0 ? stores : recentFromDash;
|
||||
const previewLeaderboard = LEADERBOARD_MOCK.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="page partner-home">
|
||||
@@ -87,7 +118,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
<span className="partner-quick-action-label">录入新店</span>
|
||||
</Link>
|
||||
<Link to="/orders" className="partner-quick-action">
|
||||
<Link to="/reshipments" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
|
||||
<span className="material-symbols-outlined">assignment_return</span>
|
||||
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
|
||||
@@ -100,7 +131,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
<span className="partner-quick-action-label">财务对账</span>
|
||||
</Link>
|
||||
<Link to="/orders" className="partner-quick-action">
|
||||
<Link to="/reports/weekly" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
|
||||
<span className="material-symbols-outlined">monitoring</span>
|
||||
</div>
|
||||
@@ -139,17 +170,69 @@ export default function HomePage() {
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{Math.ceil(storeCount * 0.08)}</span>
|
||||
<span className="label-md text-success" style={{ display: 'flex', alignItems: 'center', fontSize: 10 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>trending_up</span> 18%
|
||||
</span>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24 }}>{Math.ceil(storeCount * 0.03)}</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24 }}>{pendingStores.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingStores.length > 0 && (
|
||||
<div className="partner-pending-stores">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 8 }}>待审核门店名称</p>
|
||||
<ul className="partner-store-name-list partner-store-name-list--compact">
|
||||
{pendingStores.map((s) => (
|
||||
<li key={String(s.id)}>
|
||||
<span className="headline-md">{String(s.name || '未命名门店')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayStores.length > 0 && (
|
||||
<ul className="partner-store-name-list">
|
||||
<li className="partner-store-name-list-header">
|
||||
<span className="label-md text-muted">门店名称</span>
|
||||
<span className="label-md text-muted">状态</span>
|
||||
</li>
|
||||
{displayStores.slice(0, 5).map((s) => (
|
||||
<li key={String(s.id)}>
|
||||
<span className="headline-md">{String(s.name || '未命名门店')}</span>
|
||||
<span className="label-md text-muted">{storeStatusLabel(s)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="partner-leaderboard-section">
|
||||
<div className="partner-leaderboard-header">
|
||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
|
||||
合伙人贡献榜
|
||||
</h3>
|
||||
<Link to="/leaderboard" className="label-md text-primary">查看全部</Link>
|
||||
</div>
|
||||
{previewLeaderboard.map((entry) => (
|
||||
<div key={entry.rank} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||
{entry.name}
|
||||
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.role})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.monthStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
||||
{String(dash?.companyName || '郑州合伙人')} · 辖区管理
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
||||
|
||||
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
|
||||
按拓店数排行,数据每周更新
|
||||
</p>
|
||||
|
||||
<div style={{ padding: '0 20px 24px' }}>
|
||||
{LEADERBOARD_MOCK.map((entry) => (
|
||||
<div key={entry.rank} className="partner-leaderboard-row">
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="headline-md">
|
||||
{entry.name}
|
||||
<span className="label-md text-muted" style={{ marginLeft: 6, fontWeight: 400 }}>({entry.role})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.monthStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
type ReshipStatus = 'ALL' | 'PENDING' | 'SHIPPING' | 'DONE';
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{ id: '1', orderNo: 'DK20260301001', status: 'PENDING' as const, productName: '杜康窖藏', originalOrderNo: 'DK20260228088', address: '郑州市金水区花园路 88 号' },
|
||||
{ id: '2', orderNo: 'DK20260302002', status: 'SHIPPING' as const, productName: '杜康特曲', originalOrderNo: 'DK20260215012', address: '郑州市二七区大学路 12 号' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING: '待配送',
|
||||
SHIPPING: '配送中',
|
||||
DONE: '已完成',
|
||||
};
|
||||
|
||||
const FILTERS: { key: ReshipStatus; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待配送' },
|
||||
{ key: 'SHIPPING', label: '配送中' },
|
||||
{ key: 'DONE', label: '已完成' },
|
||||
];
|
||||
|
||||
/** BUWR-23:补发处理 */
|
||||
export default function ReshipPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<ReshipStatus>('ALL');
|
||||
|
||||
const list = MOCK_ITEMS.filter((item) => filter === 'ALL' || item.status === filter);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-orders-page">
|
||||
<PageHeader title="补发处理" onBack={() => navigate('/')} />
|
||||
|
||||
<p className="label-md text-muted" style={{ padding: '0 20px 8px' }}>
|
||||
总部下达的补发工单,确认后可推进配送
|
||||
</p>
|
||||
|
||||
<div className="partner-filter-row" style={{ padding: '0 20px 12px' }}>
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={`partner-chip${filter === f.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{list.length === 0 && <div className="empty">暂无补发工单</div>}
|
||||
|
||||
{list.map((item) => (
|
||||
<div key={item.id} className="partner-order-card" style={{ margin: '0 20px 12px' }}>
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">原单 {item.originalOrderNo}</span>
|
||||
<span className="label-md text-primary" style={{ fontWeight: 600 }}>{STATUS_LABEL[item.status]}</span>
|
||||
</div>
|
||||
<h3 className="headline-md" style={{ marginTop: 8 }}>{item.productName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>补发单号 {item.orderNo}</p>
|
||||
<div className="partner-order-address" style={{ marginTop: 12 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
||||
<p className="line-2-clamp">{item.address}</p>
|
||||
</div>
|
||||
{item.status === 'PENDING' && (
|
||||
<button type="button" className="partner-btn-primary" style={{ marginTop: 16, width: '100%' }}>
|
||||
开始配送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
clearStoreDraft,
|
||||
defaultStoreForm,
|
||||
loadStoreDraft,
|
||||
saveStoreDraft,
|
||||
type StoreDraftForm,
|
||||
validateStoreStep1,
|
||||
validateStoreStep3,
|
||||
} from '../lib/storeDraft';
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
|
||||
export default function StoreCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const step = Number(params.get('step') || 1);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
district: '金水区',
|
||||
address: '',
|
||||
intro: '',
|
||||
accountPhone: '',
|
||||
accountName: '',
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
});
|
||||
const saved = loadStoreDraft();
|
||||
const [form, setForm] = useState<StoreDraftForm>(saved?.form ?? defaultStoreForm());
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const stepFromUrl = Number(params.get('step') || 0);
|
||||
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stepFromUrl && saved?.step) {
|
||||
setParams({ step: String(saved.step) }, { replace: true });
|
||||
}
|
||||
}, [stepFromUrl, saved?.step, setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
saveStoreDraft({ step, form });
|
||||
}, [step, form]);
|
||||
|
||||
function patchForm(patch: Partial<StoreDraftForm>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
setError('');
|
||||
}
|
||||
|
||||
function goStep(n: number) {
|
||||
setParams({ step: String(n) });
|
||||
setError('');
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (step === 1) {
|
||||
const msg = validateStoreStep1(form);
|
||||
if (msg) { setError(msg); return; }
|
||||
}
|
||||
goStep(step + 1);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const msg = validateStoreStep3(form);
|
||||
if (msg) { setError(msg); return; }
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
if (step1Msg) { setError(step1Msg); return; }
|
||||
|
||||
await request('PARTNER_H5', '/partner/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
district: form.district.trim(),
|
||||
address: form.address.trim(),
|
||||
intro: form.intro.trim() || undefined,
|
||||
bankAccountName: form.bankAccountName.trim(),
|
||||
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: form.bankBranch.trim(),
|
||||
}),
|
||||
});
|
||||
clearStoreDraft();
|
||||
navigate('/stores');
|
||||
}
|
||||
|
||||
@@ -61,6 +102,10 @@ export default function StoreCreatePage() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
<p className="partner-form-error" role="alert">{error}</p>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<section className="partner-form-card">
|
||||
@@ -72,30 +117,30 @@ export default function StoreCreatePage() {
|
||||
<label>门店名称 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">store</span>
|
||||
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => patchForm({ name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>联系电话 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>区县 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<input placeholder="请选择区县" value={form.district} onChange={(e) => setForm({ ...form, district: e.target.value })} />
|
||||
<input placeholder="请选择区县" value={form.district} onChange={(e) => patchForm({ district: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>详细地址 <span className="text-primary">*</span></label>
|
||||
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店简介</label>
|
||||
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
@@ -108,7 +153,7 @@ export default function StoreCreatePage() {
|
||||
<div>
|
||||
<h3 className="body-md text-primary" style={{ fontWeight: 700 }}>杜康合伙人身份认证</h3>
|
||||
<p className="label-md text-variant" style={{ marginTop: 4, lineHeight: 1.4 }}>
|
||||
传承千年窖香,共筑美酒传奇。录入信息后,系统将在 1-3 个工作日内完成审核。
|
||||
填写内容将自动保存,退出后可继续录入。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,18 +182,12 @@ export default function StoreCreatePage() {
|
||||
<h3 className="headline-md">环境照片 *</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少3张,展示店内整洁环境</p>
|
||||
<div className="partner-upload-grid">
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<button key={i} type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -169,7 +208,7 @@ export default function StoreCreatePage() {
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.2)', borderColor: 'rgba(254,214,91,0.5)' }}>
|
||||
<span className="material-symbols-outlined text-secondary" style={{ fontSize: 18 }}>info</span>
|
||||
<p className="label-md" style={{ lineHeight: 1.5, color: 'var(--color-on-secondary-container)' }}>
|
||||
温馨提示:请确保照片清晰无反光。preV1 占位上传,可直接下一步。
|
||||
温馨提示:preV1 占位上传,可直接下一步。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -184,29 +223,21 @@ export default function StoreCreatePage() {
|
||||
<section className="partner-form-card">
|
||||
<div className="partner-field">
|
||||
<label>户主姓名 *</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => setForm({ ...form, bankAccountName: e.target.value })} />
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>银行卡号 *</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => setForm({ ...form, bankAccountNo: e.target.value })} />
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>开户支行 *</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => setForm({ ...form, bankBranch: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店账号手机</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} value={form.accountPhone} onChange={(e) => setForm({ ...form, accountPhone: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>店长姓名</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} value={form.accountName} onChange={(e) => setForm({ ...form, accountName: e.target.value })} />
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
||||
</div>
|
||||
</section>
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
|
||||
<span className="material-symbols-outlined text-secondary">info</span>
|
||||
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
|
||||
请确保银行卡信息准确,以免影响每月的餐费结算。结算将按合同约定账期执行。
|
||||
请确保银行卡信息准确,以免影响每月的餐费结算。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -217,7 +248,7 @@ export default function StoreCreatePage() {
|
||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)}>上一步</button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<button type="button" className="partner-btn-primary" onClick={() => goStep(step + 1)}>
|
||||
<button type="button" className="partner-btn-primary" onClick={handleNext}>
|
||||
<span>下一步</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,15 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'CLOSED', label: '已关闭' },
|
||||
];
|
||||
|
||||
function auditLabel(store: Record<string, unknown>) {
|
||||
const audits = store.audits as Array<{ status?: string }> | undefined;
|
||||
const s = String(audits?.[0]?.status ?? '').toUpperCase();
|
||||
if (s === 'PENDING') return { cls: 'partner-status-pill--paused', label: '待审核' };
|
||||
if (s === 'REJECTED') return { cls: 'partner-status-pill--closed', label: '已驳回' };
|
||||
if (s === 'APPROVED') return { cls: 'partner-status-pill--open', label: '已通过' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
@@ -71,16 +80,22 @@ export default function StoreListPage() {
|
||||
|
||||
{filtered.map((s) => {
|
||||
const pill = statusPill(String(s.status));
|
||||
const audit = auditLabel(s);
|
||||
const dim = String(s.status).toUpperCase() === 'CLOSED';
|
||||
const storeName = String(s.name || '未命名门店');
|
||||
return (
|
||||
<div key={String(s.id)} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||
<Link to={`/stores/${s.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<div className="partner-store-card-header">
|
||||
<div>
|
||||
<h3 className="headline-md">{String(s.name)}</h3>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>门店名称</p>
|
||||
<h3 className="headline-md">{storeName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
||||
</div>
|
||||
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
|
||||
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
|
||||
{audit && <span className={`partner-status-pill ${audit.cls}`}>{audit.label}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="partner-store-card-actions">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** BUWR-25:经营周报(preV1 占位,待 `/partner/reports/weekly` 接入) */
|
||||
export default function WeeklyReportPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||
|
||||
<main style={{ padding: '0 20px 24px' }}>
|
||||
<section className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
||||
<p className="partner-revenue-label">近 7 日 GMV (CNY)</p>
|
||||
<div className="partner-revenue-amount">{fmtMoney(128450)}</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-bento-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-expansion-split">
|
||||
<div>
|
||||
<p className="label-md text-muted">购酒订单量</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>86</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted">活跃门店数</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>12</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-bento-card">
|
||||
<h2 className="headline-md" style={{ marginBottom: 12 }}>本周新签门店</h2>
|
||||
<p className="headline-lg text-primary">3 家</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>详细趋势图待 API 接入后展示</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -581,6 +581,106 @@ html, body, #root {
|
||||
border-left: 1px solid rgba(226, 190, 188, 0.2);
|
||||
}
|
||||
|
||||
.partner-form-error {
|
||||
margin: 0 20px 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-error-container);
|
||||
color: var(--color-error);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.partner-store-name-list {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.partner-store-name-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.partner-store-name-list-header {
|
||||
padding-bottom: 4px !important;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
|
||||
}
|
||||
|
||||
.partner-store-name-list--compact li {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.partner-pending-stores {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
|
||||
}
|
||||
|
||||
.partner-field-input--block {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
min-height: 48px;
|
||||
border: 1px solid rgba(226, 190, 188, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.partner-leaderboard-section {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.partner-leaderboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.partner-leaderboard-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
|
||||
}
|
||||
|
||||
.partner-leaderboard-row--compact {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.partner-leaderboard-rank {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-container-high);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partner-leaderboard-row:first-of-type .partner-leaderboard-rank {
|
||||
background: var(--color-aged-amber);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.partner-leaderboard-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.partner-leaderboard-stat {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Partner bottom tabbar ── */
|
||||
.app-tabbar {
|
||||
display: grid;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@dukang/weixin-sdk": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: '/api/v1',
|
||||
clientApp: 'SHOP_H5',
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
@@ -27,6 +28,22 @@ export default function HomePage() {
|
||||
const openTime = String(store?.openTime || '10:00');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
|
||||
async function handleScan() {
|
||||
if (isWechatEnv()) {
|
||||
try {
|
||||
await weixinSdk.init();
|
||||
const token = await weixinSdk.scanQrCode();
|
||||
if (token) {
|
||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to manual redeem page */
|
||||
}
|
||||
}
|
||||
navigate('/redeem');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-home-page">
|
||||
<header className="shop-home-header">
|
||||
@@ -55,7 +72,7 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
<section className="shop-home-scan">
|
||||
<button type="button" className="shop-home-scan-btn" onClick={() => navigate('/redeem')}>
|
||||
<button type="button" className="shop-home-scan-btn" onClick={handleScan}>
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
</button>
|
||||
<p className="shop-home-scan-label">扫码核销</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
@@ -8,6 +8,7 @@ function formatAmount(n: number) {
|
||||
|
||||
export default function RedeemConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [token, setToken] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -20,6 +21,11 @@ export default function RedeemConfirmPage() {
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scanned = searchParams.get('token');
|
||||
if (scanned) setToken(scanned);
|
||||
}, [searchParams]);
|
||||
|
||||
async function confirm() {
|
||||
if (!token.trim()) {
|
||||
setMsg('请在开发者选项中输入核销码');
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/weixin-sdk": "workspace:*",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -4,11 +4,18 @@ import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||
|
||||
type PhoneVerifySheetProps = {
|
||||
open: boolean;
|
||||
/** 打开时预填手机号(如收货地址中的手机号) */
|
||||
defaultPhone?: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export default function PhoneVerifySheet({ open, onClose, onSuccess }: PhoneVerifySheetProps) {
|
||||
export default function PhoneVerifySheet({
|
||||
open,
|
||||
defaultPhone,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: PhoneVerifySheetProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
@@ -21,8 +28,15 @@ export default function PhoneVerifySheet({ open, onClose, onSuccess }: PhoneVeri
|
||||
setCode('');
|
||||
setMsg('');
|
||||
setCodeCooldown(0);
|
||||
return;
|
||||
}
|
||||
}, [open]);
|
||||
if (defaultPhone) {
|
||||
const normalized = normalizePhoneInput(defaultPhone);
|
||||
if (validateMobilePhone(normalized).ok) {
|
||||
setPhone(normalized);
|
||||
}
|
||||
}
|
||||
}, [open, defaultPhone]);
|
||||
|
||||
async function sendCode() {
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
|
||||
@@ -56,6 +56,15 @@ export function isLoggedIn() {
|
||||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = ['/auth/session/bootstrap', '/auth/token/refresh'];
|
||||
|
||||
async function recoverSession(): Promise<SessionPayload> {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed) return refreshed;
|
||||
clearAuth();
|
||||
return bootstrapSession();
|
||||
}
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
@@ -79,12 +88,34 @@ async function rawRequest<T>(
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
async function requestWithAuthRetry<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
retried = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await rawRequest<T>(path, options);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
const canRecover =
|
||||
err.status === 401 &&
|
||||
!retried &&
|
||||
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||||
if (!canRecover) throw e;
|
||||
await recoverSession();
|
||||
return requestWithAuthRetry<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
_clientApp: string,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
return rawRequest<T>(path, options);
|
||||
if (!isLoggedIn() && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p))) {
|
||||
await bootstrapSession();
|
||||
}
|
||||
return requestWithAuthRetry<T>(path, options);
|
||||
}
|
||||
|
||||
export async function bootstrapSession(): Promise<SessionPayload> {
|
||||
@@ -145,7 +176,10 @@ export async function ensureSession(): Promise<SessionPayload> {
|
||||
}
|
||||
|
||||
export async function bindPhone(phone: string, code: string): Promise<SessionPayload> {
|
||||
const data = await rawRequest<SessionPayload>('/auth/phone/bind', {
|
||||
if (!isLoggedIn()) {
|
||||
await bootstrapSession();
|
||||
}
|
||||
const data = await requestWithAuthRetry<SessionPayload>('/auth/phone/bind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { getWechatLocation } from '@dukang/weixin-sdk';
|
||||
import { weixinSdk } from './weixin';
|
||||
|
||||
export type ClientGpsLocation = {
|
||||
province?: string;
|
||||
city?: string;
|
||||
@@ -7,55 +10,15 @@ export type ClientGpsLocation = {
|
||||
address?: string;
|
||||
};
|
||||
|
||||
type WxLocationResult = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wx?: {
|
||||
getLocation?: (options: {
|
||||
type?: string;
|
||||
success?: (res: WxLocationResult) => void;
|
||||
fail?: () => void;
|
||||
}) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */
|
||||
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
|
||||
if (typeof window !== 'undefined' && window.wx?.getLocation) {
|
||||
const wxResult = await new Promise<WxLocationResult | null>((resolve) => {
|
||||
window.wx!.getLocation!({
|
||||
type: 'gcj02',
|
||||
success: (res) => resolve(res),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
if (wxResult) {
|
||||
return {
|
||||
latitude: wxResult.latitude,
|
||||
longitude: wxResult.longitude,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
resolve({
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
});
|
||||
},
|
||||
() => resolve(null),
|
||||
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60_000 },
|
||||
);
|
||||
});
|
||||
const loc = await weixinSdk.getLocation();
|
||||
if (!loc) return null;
|
||||
return {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 使用 tryGetClientGpsLocation */
|
||||
export { getWechatLocation };
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveSession, type UserProfile } from './api';
|
||||
|
||||
const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
||||
|
||||
export function isWechatAuthRequiredError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('USER_H5', '/common/client-config');
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||
return request<UserProfile>('USER_H5', '/auth/me');
|
||||
}
|
||||
|
||||
/** 真实微信支付且未绑定微信时需要授权 */
|
||||
export function needsWechatAuthForPay(
|
||||
config: ClientRuntimeConfig,
|
||||
profile: UserProfile | null,
|
||||
): boolean {
|
||||
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
|
||||
}
|
||||
|
||||
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
if (!result.accessToken) return false;
|
||||
saveSession({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken ?? '',
|
||||
deviceKey: result.deviceKey,
|
||||
phoneVerified: !!result.phoneVerified,
|
||||
user: result.user as never,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以完成授权');
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
export function buildLoginReturnUrl(pathname: string, search: string) {
|
||||
return `/login?return=${encodeURIComponent(`${pathname}${search}`)}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
|
||||
const CLIENT_APP = 'USER_H5';
|
||||
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: '/api/v1',
|
||||
clientApp: CLIENT_APP,
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
@@ -92,6 +92,8 @@ export default function AddressEditPage() {
|
||||
});
|
||||
}
|
||||
navigate(buildAddressListUrl(checkoutCtx));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ function formatAddress(a: Address) {
|
||||
}
|
||||
|
||||
export default function AddressListPage() {
|
||||
const [list, setList] = useState<Address[]>([]);
|
||||
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
|
||||
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request, saveSession } from '../lib/api';
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import { request, saveSession, type UserProfile } from '../lib/api';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnTo = searchParams.get('return') || '/';
|
||||
const [phone, setPhone] = useState('13800000001');
|
||||
const [code, setCode] = useState('123456');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||
const [bindMode, setBindMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv()) return;
|
||||
weixinSdk
|
||||
.handleOAuthCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
handleWechatLoginResult(result);
|
||||
})
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
|
||||
}, []);
|
||||
|
||||
function handleWechatLoginResult(result: WechatLoginResult) {
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setMsg('微信授权成功,请绑定手机号完成登录');
|
||||
return;
|
||||
}
|
||||
if (result.accessToken) {
|
||||
saveSession({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken ?? '',
|
||||
deviceKey: result.deviceKey,
|
||||
phoneVerified: !!result.phoneVerified,
|
||||
user: result.user as never,
|
||||
});
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
@@ -31,7 +67,7 @@ export default function LoginPage() {
|
||||
setMsg('');
|
||||
await request('USER_H5', '/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'USER_LOGIN' }),
|
||||
body: JSON.stringify({ phone, scene: bindMode ? 'BIND_PHONE' : 'USER_LOGIN' }),
|
||||
});
|
||||
setMsg('验证码已发送(Mock: 123456)');
|
||||
setCodeCooldown(60);
|
||||
@@ -60,6 +96,14 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
if (bindMode && wxSessionKey) {
|
||||
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ wxSessionKey, phone, code }),
|
||||
});
|
||||
handleWechatLoginResult(data);
|
||||
return;
|
||||
}
|
||||
const data = await request<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
@@ -69,7 +113,7 @@ export default function LoginPage() {
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveSession(data);
|
||||
navigate('/');
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
@@ -77,9 +121,19 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function wechatLogin() {
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录');
|
||||
setMsg('');
|
||||
try {
|
||||
if (!isWechatEnv()) {
|
||||
setMsg('请在微信内打开以使用微信一键授权');
|
||||
return;
|
||||
}
|
||||
const result = await weixinSdk.login();
|
||||
if (result) handleWechatLoginResult(result);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '微信登录失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,7 +151,7 @@ export default function LoginPage() {
|
||||
|
||||
<main className="login-main">
|
||||
<div className="login-card">
|
||||
<h3 className="login-card-title">手机验证码登录</h3>
|
||||
<h3 className="login-card-title">{bindMode ? '绑定手机号' : '手机验证码登录'}</h3>
|
||||
<div className="login-field">
|
||||
<span className="login-field-prefix">+86</span>
|
||||
<input
|
||||
@@ -138,20 +192,24 @@ export default function LoginPage() {
|
||||
disabled={loading}
|
||||
onClick={login}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="login-divider">
|
||||
<span className="login-divider-line" />
|
||||
<span className="login-divider-text">或者</span>
|
||||
<span className="login-divider-line" />
|
||||
</div>
|
||||
{!bindMode && (
|
||||
<>
|
||||
<div className="login-divider">
|
||||
<span className="login-divider-line" />
|
||||
<span className="login-divider-text">或者</span>
|
||||
<span className="login-divider-line" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
|
||||
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||
<span>微信一键授权</span>
|
||||
</button>
|
||||
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
|
||||
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||
<span>微信一键授权</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="login-footer">
|
||||
|
||||
@@ -329,6 +329,7 @@ export default function OrderConfirmPage() {
|
||||
|
||||
<PhoneVerifySheet
|
||||
open={showPhoneVerify}
|
||||
defaultPhone={selectedAddress?.phone}
|
||||
onClose={() => {
|
||||
setShowPhoneVerify(false);
|
||||
setPendingSubmit(false);
|
||||
|
||||
@@ -1,14 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import type { WechatLoginResult, WechatPayOrderResult } from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { buildOrderConfirmUrl } from '../lib/navigation';
|
||||
import {
|
||||
authorizeWechatForPay,
|
||||
buildLoginReturnUrl,
|
||||
fetchClientConfig,
|
||||
fetchUserProfile,
|
||||
isWechatAuthRequiredError,
|
||||
needsWechatAuthForPay,
|
||||
saveWechatLoginResult,
|
||||
} from '../lib/pay-wechat';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitOrderPaid(orderId: string, maxAttempts = 15) {
|
||||
for (let i = 0; i < maxAttempts; i += 1) {
|
||||
const order = await request<{ payStatus?: string }>('USER_H5', `/trade/orders/${orderId}`);
|
||||
if (order.payStatus === 'PAID') return true;
|
||||
await sleep(2000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
const [params] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const orderId = params.get('orderId') || '';
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [mockMode, setMockMode] = useState(true);
|
||||
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const refreshPayReadiness = useCallback(async () => {
|
||||
try {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
setMockMode(config.mockPay);
|
||||
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
|
||||
return profile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWechatLoginResult = useCallback(
|
||||
async (result: WechatLoginResult) => {
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
setMsg('微信授权成功,请先绑定手机号');
|
||||
navigate(buildLoginReturnUrl(location.pathname, location.search));
|
||||
return;
|
||||
}
|
||||
if (saveWechatLoginResult(result)) {
|
||||
setMsg('');
|
||||
await refreshPayReadiness();
|
||||
}
|
||||
},
|
||||
[location.pathname, location.search, navigate, refreshPayReadiness],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPayReadiness();
|
||||
}, [refreshPayReadiness]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv()) return;
|
||||
weixinSdk
|
||||
.handleOAuthCallback()
|
||||
.then((result) => {
|
||||
if (result) void handleWechatLoginResult(result);
|
||||
})
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '微信授权失败'));
|
||||
}, [handleWechatLoginResult]);
|
||||
|
||||
function goBackConfirm() {
|
||||
navigate(
|
||||
@@ -20,13 +89,56 @@ export default function PayPage() {
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function pay() {
|
||||
setLoading(true);
|
||||
|
||||
async function wechatAuthorize() {
|
||||
setAuthLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${orderId}/pay`, { method: 'POST' });
|
||||
if (!isWechatEnv()) {
|
||||
setMsg('请在微信内打开以授权微信支付');
|
||||
return;
|
||||
}
|
||||
const result = await authorizeWechatForPay();
|
||||
if (result) await handleWechatLoginResult(result);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '微信授权失败');
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (needsWechatAuth) {
|
||||
setMsg('请先完成微信授权后再支付');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (result.mode === 'jsapi' && result.prepay) {
|
||||
setMockMode(false);
|
||||
await weixinSdk.init();
|
||||
await weixinSdk.pay(result.prepay);
|
||||
const paid = await waitOrderPaid(orderId);
|
||||
if (!paid) {
|
||||
alert('支付结果确认中,请稍后在订单列表查看');
|
||||
}
|
||||
navigate('/orders?tab=pending_ship');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/orders?tab=pending_ship');
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '支付失败');
|
||||
if (isWechatAuthRequiredError(e)) {
|
||||
setNeedsWechatAuth(true);
|
||||
setMsg('微信支付需要先完成微信授权');
|
||||
return;
|
||||
}
|
||||
setMsg(e instanceof Error ? e.message : '支付失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -39,13 +151,44 @@ export default function PayPage() {
|
||||
<div className="pay-icon">
|
||||
<span className="material-symbols-outlined">account_balance_wallet</span>
|
||||
</div>
|
||||
<p className="headline-lg text-primary">Mock 微信支付</p>
|
||||
<p className="text-muted body-md" style={{ marginTop: 8 }}>preV1 环境模拟支付,点击确认即完成</p>
|
||||
<p className="headline-lg text-primary">
|
||||
{needsWechatAuth ? '授权微信后可支付' : mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
|
||||
</p>
|
||||
<p className="text-muted body-md" style={{ marginTop: 8 }}>
|
||||
{needsWechatAuth
|
||||
? '使用微信支付前,需先授权微信账号以完成付款'
|
||||
: mockMode
|
||||
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
|
||||
: '请在微信内完成支付,支付成功后自动跳转'}
|
||||
</p>
|
||||
|
||||
{needsWechatAuth && (
|
||||
<div className="pay-wechat-auth-card">
|
||||
<p className="pay-wechat-auth-title">尚未授权微信</p>
|
||||
<p className="pay-wechat-auth-desc">授权后可安全调起微信支付,不会重复扣款</p>
|
||||
<button
|
||||
type="button"
|
||||
className="login-wechat-btn pay-wechat-auth-btn"
|
||||
disabled={authLoading}
|
||||
onClick={wechatAuthorize}
|
||||
>
|
||||
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||
<span>{authLoading ? '授权中...' : '微信一键授权'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="pay-wechat-auth-msg">{msg}</p>}
|
||||
<p className="label-md text-muted" style={{ marginTop: 24 }}>订单号 {orderId}</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={loading || !orderId} onClick={pay}>
|
||||
{loading ? '支付中...' : '确认支付'}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={loading || !orderId || needsWechatAuth}
|
||||
onClick={pay}
|
||||
>
|
||||
{loading ? '支付中...' : needsWechatAuth ? '请先授权微信' : '确认支付'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
type RegionSelection,
|
||||
} from '../lib/region-data';
|
||||
|
||||
type OpenCity = {
|
||||
code: string;
|
||||
name: string;
|
||||
province: string;
|
||||
};
|
||||
|
||||
type StoreItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -25,6 +31,11 @@ type StoreItem = {
|
||||
category?: { name: string } | null;
|
||||
};
|
||||
|
||||
function resolveCityCode(region: RegionSelection, cities: OpenCity[]): string | undefined {
|
||||
if (region.province === REGION_ALL || region.city === REGION_ALL) return undefined;
|
||||
return cities.find((c) => c.province === region.province && c.name === region.city)?.code;
|
||||
}
|
||||
|
||||
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||
|
||||
@@ -43,29 +54,56 @@ function formatHours(store: StoreItem) {
|
||||
|
||||
export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [cities, setCities] = useState<OpenCity[]>([]);
|
||||
const [stores, setStores] = useState<StoreItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
||||
|
||||
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
|
||||
|
||||
useEffect(() => {
|
||||
request<StoreItem[]>('USER_H5', '/stores?cityCode=410100').then(setStores);
|
||||
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : '';
|
||||
request<StoreItem[]>('USER_H5', `/stores${qs}`)
|
||||
.then((data) => {
|
||||
if (!cancelled) setStores(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStores([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cityCode]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = stores;
|
||||
if (categoryTab !== '全部') {
|
||||
list = list.filter((s) => s.category?.name === categoryTab);
|
||||
}
|
||||
list = list.filter((s) => {
|
||||
const province = s.province ?? '河南省';
|
||||
const city = s.cityName ?? '郑州市';
|
||||
if (region.province !== REGION_ALL && province !== region.province) return false;
|
||||
if (region.city !== REGION_ALL && city !== region.city) return false;
|
||||
if (region.district !== REGION_ALL && s.district !== region.district) return false;
|
||||
return true;
|
||||
});
|
||||
if (!cityCode) {
|
||||
list = list.filter((s) => {
|
||||
const province = s.province ?? '河南省';
|
||||
const city = s.cityName ?? '郑州市';
|
||||
if (region.province !== REGION_ALL && province !== region.province) return false;
|
||||
if (region.city !== REGION_ALL && city !== region.city) return false;
|
||||
if (region.district !== REGION_ALL && s.district !== region.district) return false;
|
||||
return true;
|
||||
});
|
||||
} else if (region.district !== REGION_ALL) {
|
||||
list = list.filter((s) => s.district === region.district);
|
||||
}
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
@@ -76,7 +114,7 @@ export default function StoreListPage() {
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [stores, categoryTab, keyword, region]);
|
||||
}, [stores, categoryTab, keyword, region, cityCode]);
|
||||
|
||||
const regionLabel = formatRegion(region.province, region.city, region.district);
|
||||
|
||||
@@ -123,7 +161,8 @@ export default function StoreListPage() {
|
||||
</header>
|
||||
|
||||
<main className="store-list">
|
||||
{filtered.map((s, index) => (
|
||||
{loading && <div className="store-empty">加载门店中...</div>}
|
||||
{!loading && filtered.map((s, index) => (
|
||||
<article
|
||||
key={s.id}
|
||||
className="store-card"
|
||||
@@ -170,11 +209,11 @@ export default function StoreListPage() {
|
||||
</article>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
{!loading && filtered.length > 0 && (
|
||||
<p className="store-list-end">没有更多门店了</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -1157,6 +1157,42 @@
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.pay-wechat-auth-card {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pay-wechat-auth-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.pay-wechat-auth-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted, #999);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pay-wechat-auth-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pay-wechat-auth-msg {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-primary, #a02d30);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
+21
-14
@@ -7,21 +7,28 @@
|
||||
|
||||
## 1. 代码所有权
|
||||
|
||||
见 [`.github/CODEOWNERS`](./.github/CODEOWNERS)。端与后端模块边界:
|
||||
见 [`.github/CODEOWNERS`](./.github/CODEOWNERS)。**2 人团队**(Git 账号):
|
||||
|
||||
| 目录 | Owner | 说明 |
|
||||
|------|-------|------|
|
||||
| `apps/mini-user` | @dev-a | C端小程序 |
|
||||
| `apps/mini-partner` | @dev-b | 城市合伙人小程序 |
|
||||
| `apps/mini-hq` | @dev-c | 总部管理小程序 |
|
||||
| `apps/h5-shop` | @dev-d | 门店 H5 |
|
||||
| `server/.../modules/{iam,trade,benefit,analytics}` | @dev-a | 交易与权益域 |
|
||||
| `server/.../modules/store` | @dev-b | 门店域 |
|
||||
| `server/.../modules/{catalog,settlement,ops}` | @dev-c | 开城与结算域 |
|
||||
| `server/.../modules/redeem` | @dev-d | 核销域 |
|
||||
| `server/.../callbacks`, `jobs`, `common` | @backend-lead | 回调与任务 |
|
||||
| `packages/*` | 全员 + @tech-lead | 公共契约,破坏性改动谨慎 |
|
||||
| `server/dukang-api/prisma` | 全部 Owner | 迁移必须多人 Review |
|
||||
| Git 账号 | 角色 | 主责 |
|
||||
|----------|------|------|
|
||||
| `jacy-dukang` | 管理员 + 主责开发 | C 端、admin-web、后端主模块、横切 |
|
||||
| `刘京尧` | 开发 | 合伙人 H5、门店 H5、store/redeem 模块 |
|
||||
|
||||
端与后端模块边界:
|
||||
|
||||
| 目录 | 负责人 | 说明 |
|
||||
|------|--------|------|
|
||||
| `apps/h5-user` | jacy-dukang | C 端 H5(V2 → mini-user) |
|
||||
| `apps/admin-web` | jacy-dukang | preV1 总部替代 |
|
||||
| `apps/h5-partner` | 刘京尧 | 合伙人 H5 |
|
||||
| `apps/h5-shop` | 刘京尧 | 门店 H5 |
|
||||
| `server/.../modules/{iam,trade,benefit,analytics}` | jacy-dukang | 交易与权益域 |
|
||||
| `server/.../modules/{catalog,settlement,ops}` | jacy-dukang | 开城与结算域 |
|
||||
| `server/.../modules/store` | 刘京尧 | 门店域 |
|
||||
| `server/.../modules/redeem` | 刘京尧 | 核销域 |
|
||||
| `server/.../callbacks`, `jobs`, `common`, `integrations` | jacy-dukang | 回调与任务 |
|
||||
| `packages/*` | jacy-dukang 主责;破坏性改动 @刘京尧 | 公共契约 |
|
||||
| `server/dukang-api/prisma` | jacy-dukang + 刘京尧(涉及 store/redeem 表时) | 迁移 Review |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "dukang-haoke",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dukang-haoke",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
"db:seed": "pnpm --filter @dukang/api prisma:seed",
|
||||
"db:sync-benefit": "pnpm --filter @dukang/api prisma:sync-benefit",
|
||||
"db:validate": "pnpm --filter @dukang/api prisma:validate",
|
||||
"oss:cors": "node scripts/configure-oss-cors.mjs",
|
||||
"sync:stitch": "node scripts/sync-stitch.mjs"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -4,6 +4,12 @@ export interface AppConfig {
|
||||
mockPay: boolean;
|
||||
mockDeliveryAuto: boolean;
|
||||
autoApproveStore: boolean;
|
||||
wechatAuthEnabled: boolean;
|
||||
wechatPayEnabled: boolean;
|
||||
wxAppId: string;
|
||||
wxMchId: string;
|
||||
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
|
||||
ossEnabled: boolean;
|
||||
}
|
||||
|
||||
export function loadAppConfig(env?: Record<string, string | undefined>): AppConfig {
|
||||
@@ -17,5 +23,10 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
||||
mockPay: e.MOCK_PAY !== 'false',
|
||||
mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false',
|
||||
autoApproveStore: e.AUTO_APPROVE_STORE !== 'false',
|
||||
wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
|
||||
wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
|
||||
wxAppId: e.WX_APP_ID ?? '',
|
||||
wxMchId: e.WX_MCH_ID ?? '',
|
||||
ossEnabled: e.OSS_ENABLED === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './enums';
|
||||
export * from './api';
|
||||
export * from './config';
|
||||
export * from './wechat';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** 微信 JSSDK 初始化参数(后端签名下发) */
|
||||
export interface WechatJssdkConfig {
|
||||
appId: string;
|
||||
timestamp: number;
|
||||
nonceStr: string;
|
||||
signature: string;
|
||||
jsApiList: string[];
|
||||
}
|
||||
|
||||
/** JSAPI 调起支付参数 */
|
||||
export interface WechatJsapiPrepayParams {
|
||||
appId: string;
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: 'RSA' | 'MD5';
|
||||
paySign: string;
|
||||
}
|
||||
|
||||
export type WechatPayOrderResult =
|
||||
| { mode: 'mock'; externalNo: string; order?: Record<string, unknown> }
|
||||
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
|
||||
|
||||
/** 业务错误码:微信支付前需完成微信授权 */
|
||||
export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
||||
|
||||
export type ClientRuntimeConfig = {
|
||||
mockPay: boolean;
|
||||
wechatPayEnabled: boolean;
|
||||
};
|
||||
|
||||
export interface WechatLoginResult {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
deviceKey?: string;
|
||||
actorType?: string;
|
||||
actorId?: string;
|
||||
phoneVerified?: boolean;
|
||||
needBindPhone?: boolean;
|
||||
wxSessionKey?: string;
|
||||
user?: Record<string, unknown>;
|
||||
store?: Record<string, unknown>;
|
||||
partner?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type WechatLoginPlatform = 'h5' | 'mini';
|
||||
|
||||
export interface WechatGpsLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
speed?: number;
|
||||
accuracy?: number;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@dukang/weixin-sdk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/shared-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { WechatLoginPlatform, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
const OAUTH_STATE_KEY = 'dukang_wx_oauth_state';
|
||||
|
||||
function randomState() {
|
||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
config: WeixinSdkConfig,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': config.clientApp,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const token = config.getAccessToken?.();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const apiBase = config.apiBase ?? '/api/v1';
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
/** 获取公众号 OAuth 授权跳转 URL */
|
||||
export async function getWechatOAuthUrl(
|
||||
config: WeixinSdkConfig,
|
||||
redirectUri: string,
|
||||
scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_userinfo',
|
||||
): Promise<string> {
|
||||
const state = randomState();
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state);
|
||||
const qs = new URLSearchParams({ redirectUri, scope, state });
|
||||
const data = await apiRequest<{ url: string }>(config, `/common/wechat/oauth-url?${qs}`);
|
||||
return data.url;
|
||||
}
|
||||
|
||||
/** 发起微信 OAuth 登录(H5 公众号内跳转授权) */
|
||||
export async function startWechatOAuthLogin(config: WeixinSdkConfig, redirectUri?: string): Promise<void> {
|
||||
if (!isWechatBrowser()) {
|
||||
throw new Error('请在微信内打开');
|
||||
}
|
||||
const uri = redirectUri ?? window.location.href.split('#')[0];
|
||||
const url = await getWechatOAuthUrl(config, uri);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
/** 小程序 wx.login 获取 code */
|
||||
export function getMiniProgramLoginCode(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.wx?.login) {
|
||||
reject(new Error('wx.login 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.login({
|
||||
success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到 code'))),
|
||||
fail: (err) => reject(new Error(err.errMsg || 'wx.login 失败')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 用 code 完成微信登录(自动识别 H5 / 小程序) */
|
||||
export async function loginWithWechatCode(
|
||||
config: WeixinSdkConfig,
|
||||
code: string,
|
||||
platform?: WechatLoginPlatform,
|
||||
): Promise<WechatLoginResult> {
|
||||
const resolvedPlatform = platform ?? (getRuntimePlatform() === 'mini' ? 'mini' : 'h5');
|
||||
return apiRequest<WechatLoginResult>(config, '/auth/login/wechat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code, platform: resolvedPlatform }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 处理 OAuth 回调 URL 中的 code 参数并登录 */
|
||||
export async function handleWechatOAuthCallback(
|
||||
config: WeixinSdkConfig,
|
||||
searchParams?: URLSearchParams,
|
||||
): Promise<WechatLoginResult | null> {
|
||||
const params = searchParams ?? new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
if (!code) return null;
|
||||
|
||||
const state = params.get('state');
|
||||
const saved = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
if (saved && state && saved !== state) {
|
||||
throw new Error('OAuth state 校验失败');
|
||||
}
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
|
||||
return loginWithWechatCode(config, code, 'h5');
|
||||
}
|
||||
|
||||
/** 微信首登绑定手机号 */
|
||||
export async function bindWechatPhone(
|
||||
config: WeixinSdkConfig,
|
||||
payload: { wxSessionKey: string; phone: string; code: string },
|
||||
): Promise<WechatLoginResult> {
|
||||
return apiRequest<WechatLoginResult>(config, '/auth/wechat/bind-phone', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取微信手机号(小程序 getPhoneNumber 返回的 code,需后端解密) */
|
||||
export async function getWechatPhoneNumber(config: WeixinSdkConfig): Promise<string> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform !== 'mini') {
|
||||
throw new Error('H5 环境请使用短信验证码绑定手机号');
|
||||
}
|
||||
const phoneCode = await new Promise<string>((resolve, reject) => {
|
||||
if (!window.wx?.getPhoneNumber) {
|
||||
reject(new Error('getPhoneNumber 不可用'));
|
||||
return;
|
||||
}
|
||||
window.wx.getPhoneNumber({
|
||||
success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到手机号 code'))),
|
||||
fail: (err) => reject(new Error(err.errMsg || '获取手机号失败')),
|
||||
});
|
||||
});
|
||||
const data = await apiRequest<{ phone: string }>(config, '/common/wechat/phone-number', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: phoneCode, platform: 'mini' }),
|
||||
});
|
||||
return data.phone;
|
||||
}
|
||||
|
||||
/** 一键微信登录(自动选择 OAuth 或 wx.login) */
|
||||
export async function wechatLogin(config: WeixinSdkConfig): Promise<WechatLoginResult | void> {
|
||||
const platform = getRuntimePlatform();
|
||||
if (platform === 'mini') {
|
||||
const code = await getMiniProgramLoginCode();
|
||||
return loginWithWechatCode(config, code, 'mini');
|
||||
}
|
||||
if (platform === 'wechat-h5') {
|
||||
await startWechatOAuthLogin(config);
|
||||
return;
|
||||
}
|
||||
throw new Error('请在微信内打开以使用微信登录');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 是否微信内置浏览器 */
|
||||
export function isWechatBrowser(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /MicroMessenger/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/** 是否微信小程序 web-view 或独立小程序环境 */
|
||||
export function isMiniProgram(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
return ua.includes('miniprogram') || (window as Window & { __wxjs_environment?: string }).__wxjs_environment === 'miniprogram';
|
||||
}
|
||||
|
||||
export function getRuntimePlatform(): 'mini' | 'wechat-h5' | 'browser' {
|
||||
if (isMiniProgram()) return 'mini';
|
||||
if (isWechatBrowser()) return 'wechat-h5';
|
||||
return 'browser';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
||||
export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
|
||||
export {
|
||||
getWechatLocation,
|
||||
canUseWechatLocation,
|
||||
isWechatEnv,
|
||||
} from './location';
|
||||
export { scanQrCode } from './scan';
|
||||
export { invokeWechatPay } from './pay';
|
||||
export {
|
||||
getWechatOAuthUrl,
|
||||
startWechatOAuthLogin,
|
||||
getMiniProgramLoginCode,
|
||||
loginWithWechatCode,
|
||||
handleWechatOAuthCallback,
|
||||
bindWechatPhone,
|
||||
getWechatPhoneNumber,
|
||||
wechatLogin,
|
||||
} from './auth';
|
||||
export type { WeixinSdkConfig, WxApi, MiniProgramWx } from './types';
|
||||
export { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
import { initWechatJssdk } from './jssdk';
|
||||
import { getWechatLocation } from './location';
|
||||
import { scanQrCode } from './scan';
|
||||
import { invokeWechatPay } from './pay';
|
||||
import {
|
||||
wechatLogin,
|
||||
handleWechatOAuthCallback,
|
||||
bindWechatPhone,
|
||||
getWechatPhoneNumber,
|
||||
} from './auth';
|
||||
|
||||
/** 微信 SDK 门面(按端注入 apiBase / clientApp) */
|
||||
export function createWeixinSdk(config: WeixinSdkConfig) {
|
||||
return {
|
||||
init: () => initWechatJssdk({ ...config, apiBase: config.apiBase ?? '/api/v1' }),
|
||||
login: () => wechatLogin(config),
|
||||
handleOAuthCallback: (params?: URLSearchParams) => handleWechatOAuthCallback(config, params),
|
||||
bindPhone: (payload: { wxSessionKey: string; phone: string; code: string }) =>
|
||||
bindWechatPhone(config, payload),
|
||||
getPhoneNumber: () => getWechatPhoneNumber(config),
|
||||
getLocation: () => getWechatLocation(config),
|
||||
scanQrCode: () => scanQrCode(config),
|
||||
pay: (prepay: Parameters<typeof invokeWechatPay>[0]) =>
|
||||
invokeWechatPay(prepay, { apiBase: config.apiBase, clientApp: config.clientApp }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { WechatJssdkConfig } from '@dukang/shared-types';
|
||||
import { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
let configured = false;
|
||||
|
||||
function loadScript(): Promise<void> {
|
||||
if (typeof document === 'undefined') return Promise.reject(new Error('非浏览器环境'));
|
||||
if (window.wx) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
|
||||
scriptPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>('script[data-dukang-wx-jssdk]');
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve());
|
||||
existing.addEventListener('error', () => reject(new Error('微信 JSSDK 加载失败')));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = JSSDK_URL;
|
||||
script.async = true;
|
||||
script.dataset.dukangWxJssdk = '1';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('微信 JSSDK 加载失败'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
async function fetchJssdkConfig(apiBase: string, clientApp: string, url: string, token?: string | null) {
|
||||
const headers: Record<string, string> = { 'X-Client-App': clientApp };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const qs = new URLSearchParams({ url });
|
||||
const res = await fetch(`${apiBase}/common/wechat/jssdk-config?${qs}`, { headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '获取 JSSDK 配置失败');
|
||||
return json.data as WechatJssdkConfig;
|
||||
}
|
||||
|
||||
/** 初始化微信 JSSDK(H5 公众号内) */
|
||||
export async function initWechatJssdk(options: {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
url?: string;
|
||||
getAccessToken?: () => string | null;
|
||||
jsApiList?: string[];
|
||||
}): Promise<void> {
|
||||
const { apiBase, clientApp, getAccessToken } = options;
|
||||
const pageUrl = options.url ?? (typeof window !== 'undefined' ? window.location.href.split('#')[0] : '');
|
||||
await loadScript();
|
||||
if (!window.wx) throw new Error('微信 JSSDK 不可用');
|
||||
|
||||
const config = await fetchJssdkConfig(apiBase, clientApp, pageUrl, getAccessToken?.());
|
||||
const jsApiList = options.jsApiList ?? [...DEFAULT_JS_API_LIST];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
window.wx!.config({
|
||||
...config,
|
||||
jsApiList,
|
||||
debug: false,
|
||||
});
|
||||
window.wx!.ready(() => {
|
||||
configured = true;
|
||||
resolve();
|
||||
});
|
||||
window.wx!.error((err) => reject(new Error(err.errMsg || 'wx.config 失败')));
|
||||
});
|
||||
}
|
||||
|
||||
export function isJssdkReady(): boolean {
|
||||
return configured && !!window.wx;
|
||||
}
|
||||
|
||||
export async function ensureJssdkReady(options: {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getAccessToken?: () => string | null;
|
||||
}): Promise<void> {
|
||||
if (!isJssdkReady()) {
|
||||
await initWechatJssdk(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { WechatGpsLocation } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import { ensureJssdkReady } from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */
|
||||
export async function getWechatLocation(config?: WeixinSdkConfig): Promise<WechatGpsLocation | null> {
|
||||
const platform = getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini' && window.wx?.getLocation) {
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.getLocation!({
|
||||
type: 'gcj02',
|
||||
success: (res) => resolve(res),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5' && config) {
|
||||
try {
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
});
|
||||
if (window.wx?.getLocation) {
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.getLocation!({
|
||||
type: 'gcj02',
|
||||
success: (res) => resolve(res),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
|
||||
return new Promise((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) =>
|
||||
resolve({
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy,
|
||||
}),
|
||||
() => resolve(null),
|
||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function canUseWechatLocation(): boolean {
|
||||
return getRuntimePlatform() !== 'browser' || typeof navigator?.geolocation !== 'undefined';
|
||||
}
|
||||
|
||||
export function isWechatEnv(): boolean {
|
||||
return isWechatBrowser() || getRuntimePlatform() === 'mini';
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
|
||||
import { getRuntimePlatform } from './env';
|
||||
import { ensureJssdkReady } from './jssdk';
|
||||
|
||||
function invokeBridgePay(params: WechatJsapiPrepayParams): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bridge = window.WeixinJSBridge;
|
||||
if (!bridge) {
|
||||
reject(new Error('WeixinJSBridge 不可用'));
|
||||
return;
|
||||
}
|
||||
bridge.invoke(
|
||||
'getBrandWCPayRequest',
|
||||
{
|
||||
appId: params.appId,
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType,
|
||||
paySign: params.paySign,
|
||||
},
|
||||
(res) => {
|
||||
const msg = res.err_msg ?? '';
|
||||
if (msg.includes('ok')) resolve();
|
||||
else if (msg.includes('cancel')) reject(new Error('用户取消支付'));
|
||||
else reject(new Error(msg || '支付失败'));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 调起微信支付 */
|
||||
export async function invokeWechatPay(
|
||||
prepay: WechatJsapiPrepayParams,
|
||||
config?: { apiBase?: string; clientApp?: string },
|
||||
): Promise<void> {
|
||||
const platform = getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini' && window.wx?.requestPayment) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.wx!.requestPayment!({
|
||||
timeStamp: prepay.timeStamp,
|
||||
nonceStr: prepay.nonceStr,
|
||||
package: prepay.package,
|
||||
signType: prepay.signType,
|
||||
paySign: prepay.paySign,
|
||||
success: () => resolve(),
|
||||
fail: (err) => reject(new Error(err.errMsg || '支付失败')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
if (config?.clientApp) {
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
});
|
||||
}
|
||||
if (window.wx?.chooseWXPay) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.wx!.chooseWXPay!({
|
||||
...prepay,
|
||||
success: () => resolve(),
|
||||
fail: (err) => reject(new Error(err.errMsg || '支付失败')),
|
||||
cancel: () => reject(new Error('用户取消支付')),
|
||||
});
|
||||
});
|
||||
}
|
||||
if (window.WeixinJSBridge) {
|
||||
return invokeBridgePay(prepay);
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (window.WeixinJSBridge) resolve();
|
||||
else document.addEventListener('WeixinJSBridgeReady', () => resolve(), { once: true });
|
||||
});
|
||||
return invokeBridgePay(prepay);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('当前环境不支持微信支付');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getRuntimePlatform } from './env';
|
||||
import { ensureJssdkReady } from './jssdk';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
/** 调起扫码(返回二维码/条码内容,失败返回 null) */
|
||||
export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null> {
|
||||
const platform = getRuntimePlatform();
|
||||
|
||||
if (platform === 'mini' && window.wx?.scanCode) {
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.scanCode!({
|
||||
onlyFromCamera: true,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => resolve(res.result || null),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'wechat-h5') {
|
||||
await ensureJssdkReady({
|
||||
apiBase: config.apiBase ?? '/api/v1',
|
||||
clientApp: config.clientApp,
|
||||
getAccessToken: config.getAccessToken,
|
||||
});
|
||||
if (window.wx?.scanQRCode) {
|
||||
return new Promise((resolve) => {
|
||||
window.wx!.scanQRCode!({
|
||||
needResult: 1,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => resolve(res.resultStr || null),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null;
|
||||
return manual?.trim() || null;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { WechatGpsLocation, WechatJsapiPrepayParams, WechatJssdkConfig } from '@dukang/shared-types';
|
||||
|
||||
export type WeixinSdkConfig = {
|
||||
/** API 根路径,默认 /api/v1 */
|
||||
apiBase?: string;
|
||||
/** X-Client-App 请求头 */
|
||||
clientApp: string;
|
||||
/** 获取 access token(可选,登录后自动带) */
|
||||
getAccessToken?: () => string | null;
|
||||
};
|
||||
|
||||
export type WxInvokeResult<T> = {
|
||||
errMsg: string;
|
||||
} & T;
|
||||
|
||||
export type WxApi = {
|
||||
config: (options: WechatJssdkConfig & { debug?: boolean }) => void;
|
||||
ready: (cb: () => void) => void;
|
||||
error: (cb: (res: { errMsg: string }) => void) => void;
|
||||
checkJsApi: (options: { jsApiList: string[]; success?: (res: Record<string, boolean>) => void }) => void;
|
||||
getLocation: (options: {
|
||||
type?: string;
|
||||
success?: (res: WechatGpsLocation) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
scanQRCode: (options: {
|
||||
needResult?: 0 | 1;
|
||||
scanType?: Array<'qrCode' | 'barCode'>;
|
||||
success?: (res: { resultStr: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
chooseWXPay: (options: WechatJsapiPrepayParams & {
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
cancel?: () => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type MiniProgramWx = {
|
||||
login: (options: {
|
||||
success?: (res: { code: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getPhoneNumber: (options: {
|
||||
success?: (res: { code: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
getLocation: WxApi['getLocation'];
|
||||
scanCode: (options: {
|
||||
onlyFromCamera?: boolean;
|
||||
scanType?: Array<'qrCode' | 'barCode'>;
|
||||
success?: (res: { result: string }) => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
requestPayment: (options: {
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
success?: () => void;
|
||||
fail?: (res: { errMsg: string }) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wx?: WxApi & MiniProgramWx;
|
||||
WeixinJSBridge?: {
|
||||
invoke: (
|
||||
api: string,
|
||||
params: Record<string, unknown>,
|
||||
cb: (res: { err_msg?: string }) => void,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_JS_API_LIST = [
|
||||
'getLocation',
|
||||
'scanQRCode',
|
||||
'chooseWXPay',
|
||||
'updateAppMessageShareData',
|
||||
'updateTimelineShareData',
|
||||
] as const;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2020", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+451
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 为 OSS Bucket 配置浏览器直传所需的 CORS 规则。
|
||||
* 使用 server/dukang-api/.env 中的 OSS 凭证。
|
||||
*
|
||||
* 用法:pnpm oss:cors
|
||||
* 可选环境变量 OSS_CORS_ORIGINS(逗号分隔),默认包含本地 H5 / admin 端口。
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const apiRoot = resolve(__dirname, '../server/dukang-api');
|
||||
const require = createRequire(resolve(apiRoot, 'package.json'));
|
||||
const OSS = require('ali-oss');
|
||||
const envPath = resolve(apiRoot, '.env');
|
||||
|
||||
function loadEnvFile(path: string) {
|
||||
if (!existsSync(path)) return;
|
||||
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(envPath);
|
||||
|
||||
const {
|
||||
OSS_ACCESS_KEY_ID: accessKeyId,
|
||||
OSS_ACCESS_KEY_SECRET: accessKeySecret,
|
||||
OSS_BUCKET: bucket,
|
||||
OSS_REGION: region = 'oss-cn-hangzhou',
|
||||
OSS_CORS_ORIGINS,
|
||||
} = process.env;
|
||||
|
||||
if (!accessKeyId || !accessKeySecret || !bucket) {
|
||||
console.error('请在 server/dukang-api/.env 配置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_BUCKET');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultOrigins = [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://localhost:5175',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
'http://127.0.0.1:5175',
|
||||
];
|
||||
|
||||
const allowedOrigin = (OSS_CORS_ORIGINS ?? defaultOrigins.join(','))
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const client = new OSS({
|
||||
region,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
bucket,
|
||||
});
|
||||
|
||||
const rules = [
|
||||
{
|
||||
allowedOrigin,
|
||||
allowedMethod: ['GET', 'POST', 'PUT', 'HEAD'],
|
||||
allowedHeader: ['*'],
|
||||
exposeHeader: ['ETag', 'x-oss-request-id'],
|
||||
maxAgeSeconds: 600,
|
||||
},
|
||||
];
|
||||
|
||||
console.log(`配置 Bucket「${bucket}」CORS,允许来源:`);
|
||||
for (const origin of allowedOrigin) console.log(` - ${origin}`);
|
||||
|
||||
await client.putBucketCORS(bucket, rules);
|
||||
console.log('CORS 规则已写入。若 H5 端仍直传 OSS,请确认来源域名已包含在列表中。');
|
||||
@@ -10,4 +10,40 @@ MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
|
||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
||||
# TRUST_PROXY=true
|
||||
TRUST_PROXY=true
|
||||
|
||||
# 微信SDK(WECHAT_AUTH_ENABLED=true 时生效)
|
||||
WX_APP_ID=
|
||||
WX_APP_SECRET=
|
||||
WECHAT_AUTH_ENABLED=false
|
||||
WECHAT_PAY_ENABLED=false
|
||||
WX_MCH_ID=
|
||||
WX_MCH_SERIAL_NO=
|
||||
WX_MCH_PRIVATE_KEY=
|
||||
WX_API_V3_KEY=
|
||||
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
||||
WX_PLATFORM_CERT=
|
||||
WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
|
||||
|
||||
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
||||
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
|
||||
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
|
||||
OSS_ENABLED=false
|
||||
OSS_ACCESS_KEY_ID=
|
||||
OSS_ACCESS_KEY_SECRET=
|
||||
OSS_BUCKET=
|
||||
OSS_REGION=oss-cn-hangzhou
|
||||
# 可选:自定义 Endpoint(如内网 oss-cn-hangzhou-internal.aliyuncs.com)
|
||||
OSS_ENDPOINT=
|
||||
# 可选:开启 V4 签名(需同步调整前端 PostObject 字段,默认 false 使用 V1)
|
||||
OSS_AUTHORIZATION_V4=false
|
||||
# 公网访问域名:自定义 CDN 或 https://{bucket}.{region}.aliyuncs.com
|
||||
OSS_CDN_BASE=
|
||||
# 直传对象前缀(默认 uploads)
|
||||
OSS_UPLOAD_PREFIX=uploads
|
||||
# 直传凭证有效期(秒,默认 900)
|
||||
OSS_UPLOAD_EXPIRE_SECONDS=900
|
||||
# 单文件大小上限(字节,默认 10MB)
|
||||
OSS_MAX_UPLOAD_BYTES=10485760
|
||||
# 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加
|
||||
# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
|
||||
@@ -0,0 +1,75 @@
|
||||
# 杜康好客 · 后端 AGENTS.md
|
||||
|
||||
> 父级:[`../AGENTS.md`](../AGENTS.md) · API 契约:V2 手册 §六 · 表结构:§五
|
||||
|
||||
## 范围
|
||||
|
||||
本目录 = 唯一后端进程 `dukang-api`(NestJS 10 + Prisma 5)。
|
||||
|
||||
## 模块 → 表 OWNER(写权限)
|
||||
|
||||
| Module | 主要 Prisma Model | 负责人 |
|
||||
|--------|-------------------|--------|
|
||||
| **iam** | User, UserAddress, … | jacy-dukang |
|
||||
| **catalog** | CommonCity, CommonProductItem, … | jacy-dukang |
|
||||
| **store** | Store, Partner | 刘京尧 |
|
||||
| **trade** | Order, OrderDelivery | jacy-dukang |
|
||||
| **benefit** | BenefitCoupon | jacy-dukang |
|
||||
| **redeem** | RedeemRecord, StoreRating | 刘京尧 |
|
||||
| **settlement** | StorePayout, PartnerBill | jacy-dukang |
|
||||
| **ops** | 只读聚合 | jacy-dukang |
|
||||
| **analytics** | LogUserAnalytics | jacy-dukang |
|
||||
| **common** | CommonResource, CommonEvent, CommonTicket | jacy-dukang |
|
||||
| **integrations** | 无表 | jacy-dukang |
|
||||
|
||||
**log_***:`LogThirdParty` 由写入方 Module 负责(支付→trade,短信→iam/notify)。
|
||||
|
||||
## 允许依赖(摘要)
|
||||
|
||||
```
|
||||
trade → catalog, store, benefit, iam, notify, common, domain
|
||||
benefit → iam, common, domain(禁止 → trade/redeem)
|
||||
redeem → benefit, store, settlement, iam, notify, common, domain
|
||||
store → catalog, iam, common, domain(禁止 → trade/benefit)
|
||||
settlement → trade, redeem, store, catalog, iam, notify, common, domain
|
||||
callbacks → trade, benefit, settlement, common(薄层,无业务表)
|
||||
jobs → trade, settlement, notify, common
|
||||
```
|
||||
|
||||
完整矩阵见 V2 手册 §四 §2.5.2。
|
||||
|
||||
## 新接口 checklist
|
||||
|
||||
1. 在 **OWNER 模块** 建 `dto/`、`*.controller.ts`、`*.service.ts`
|
||||
2. Service 只写本模块表;跨域 inject exported Service
|
||||
3. Guard:`JwtAuthGuard` + `actorType` 校验(User/Store/Partner/HQ)
|
||||
4. 响应 `{ code: 0, message: 'ok', data }`(`ResponseInterceptor`)
|
||||
5. DTO/枚举同步 `packages/shared-types`
|
||||
6. preV1 Mock:改 `integrations/*`,不在 Controller 散落 `if (mock)`
|
||||
|
||||
## Mock 集成(preV1)
|
||||
|
||||
```
|
||||
integrations/
|
||||
├── sms/sms.mock.provider.ts
|
||||
├── pay/pay.mock.provider.ts
|
||||
└── delivery/delivery.mock.provider.ts
|
||||
```
|
||||
|
||||
开关见 `packages/shared-types/src/config.ts`(`MOCK_SMS`, `MOCK_PAY`, `MOCK_DELIVERY_AUTO`)。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm dev:api
|
||||
pnpm db:validate
|
||||
pnpm db:seed
|
||||
cd server/dukang-api && npx prisma studio
|
||||
```
|
||||
|
||||
## 禁止
|
||||
|
||||
- 在 `redeem` 里 `prisma.order.update` → 调 `TradeService`
|
||||
- 在 `benefit` 里 inject `TradeService` / `RedeemService`
|
||||
- 多个 Module 注册同一 callback 路由
|
||||
- 复制起购/核销规则到 Controller → 用 `packages/domain`
|
||||
@@ -10,7 +10,8 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:validate": "prisma validate",
|
||||
"prisma:seed": "ts-node --transpile-only prisma/seed-prev1.ts",
|
||||
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
||||
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
|
||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -23,6 +24,7 @@
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@prisma/client": "^5.18.0",
|
||||
"ali-oss": "^6.23.0",
|
||||
"bullmq": "^5.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
@@ -34,7 +36,9 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"prisma": "^5.18.0",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# V3.1 Schema 迁移清单
|
||||
|
||||
> 生成自 `init_v3.sql` → `schema.v31.prisma`(已通过 `prisma validate`)
|
||||
> 旧版备份:`schema.legacy-v21.prisma`(当前运行中的 `schema.prisma`)
|
||||
|
||||
## 激活步骤(P0)
|
||||
|
||||
```bash
|
||||
cd server/dukang-api
|
||||
# 1. 备份并切换 schema
|
||||
cp prisma/schema.prisma prisma/schema.legacy-v21.prisma # 若尚未备份
|
||||
cp prisma/schema.v31.prisma prisma/schema.prisma
|
||||
|
||||
# 2. 开发库建议删库重建
|
||||
mysql -u root -p -e "DROP DATABASE IF EXISTS dukang_haoke; CREATE DATABASE dukang_haoke ..."
|
||||
# 或:mysql < prisma/init_v3.sql
|
||||
|
||||
# 3. 生成 Client
|
||||
pnpm db:generate
|
||||
npx prisma db push # 或 prisma migrate dev --name v31_init
|
||||
|
||||
# 4. 新 seed(待编写 seed-v31.ts)
|
||||
pnpm prisma:seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 表对照(27 张 v3.1)
|
||||
|
||||
| v3.1 物理表 | Prisma Model | 旧表/Model | 变化 |
|
||||
|-------------|--------------|------------|------|
|
||||
| `common_wx_app_config` | `CommonWxAppConfig` | `wx_app_configs` / `WxAppConfig` | 重命名 |
|
||||
| `common_resource` | `CommonResource` | — | **新增**(替代 `store_media`、裸 URL) |
|
||||
| `common_event` | `CommonEvent` | `benefit_ledgers`, `order_status_logs`, `store_audits`, `event_logs`, `operation_logs` | **合并** |
|
||||
| `common_ticket` | `CommonTicket` | `after_sale_tickets`, `refunds`, `delivery_intercepts`, `alerts` | **合并** |
|
||||
| `common_product_item` | `CommonProductItem` | `products` / `Product` | 重命名 + `cover_resource_id` |
|
||||
| `common_store_category` | `CommonStoreCategory` | `store_categories` / `StoreCategory` | 重命名 |
|
||||
| `common_promo_code` | `CommonPromoCode` | `promo_codes` / `PromoCode` | 重命名 + `qrcode_resource_id` |
|
||||
| `common_city` | `CommonCity` | `cities` / `City` | 重命名 |
|
||||
| `common_city_commission_rule` | `CommonCityCommissionRule` | `city_commission_rules` | 重命名 |
|
||||
| `partner_partner` | `Partner` | `partners` | 重命名 + 合同字段内联 |
|
||||
| `partner_account` | `PartnerAccount` | `partner_accounts` | 重命名 |
|
||||
| `partner_bill` | `PartnerBill` | `partner_bills` | 状态枚举精简 |
|
||||
| `hq_account` | `HqAccount` | `hq_accounts` | 重命名 |
|
||||
| `user_user` | `User` | `users` | 重命名;`phone` 可空;含 `deviceKey`/合并字段 |
|
||||
| `user_address` | `UserAddress` | `user_addresses` | 重命名 |
|
||||
| `user_city_preference` | `UserCityPreference` | `user_city_preferences` | 重命名 |
|
||||
| `user_promo_attribution` | `UserPromoAttribution` | `user_promo_attributions` | 重命名 |
|
||||
| `store_store` | `Store` | `stores` | `cover_url` → `cover_resource_id` |
|
||||
| `store_account` | `StoreAccount` | `store_accounts` | 重命名 |
|
||||
| `user_order` | `Order` | `orders` | **无 order_items**;商品快照内嵌 |
|
||||
| `user_order_delivery` | `OrderDelivery` | `order_deliveries` | 重命名 + `sign_photo_resource_id` |
|
||||
| `user_benefit_coupon` | `BenefitCoupon` | `benefit_coupons` | 重命名 |
|
||||
| `user_redeem_record` | `RedeemRecord` | `redeem_records` | 重命名 |
|
||||
| `user_store_rating` | `StoreRating` | `store_ratings` | 重命名 |
|
||||
| `store_payout` | `StorePayout` | `store_payouts` | 重命名 |
|
||||
| `log_third_party` | `LogThirdParty` | `payments`, `sms_logs` | **合并** |
|
||||
| `log_user_analytics` | `LogUserAnalytics` | `event_logs`(埋点部分) | **拆分** |
|
||||
|
||||
### 删除的表(v3.1 不再存在)
|
||||
|
||||
| 旧表 | 替代方案 |
|
||||
|------|----------|
|
||||
| `store_media` | `common_resource`(`owner_type=STORE`) |
|
||||
| `partner_contracts` | `partner_partner.contract_*` + `common_resource` CONTRACT |
|
||||
| `order_items` | `user_order` 内嵌快照字段 |
|
||||
| `order_status_logs` | `common_event(ORDER_STATUS)` |
|
||||
| `payments` | `log_third_party` + `user_order.pay_*` |
|
||||
| `refunds` | `common_ticket(REFUND)` |
|
||||
| `delivery_intercepts` | `common_ticket(ALERT)` 或 RESHIPMENT |
|
||||
| `benefit_ledgers` | `common_event(BENEFIT_LEDGER)` |
|
||||
| `redeem_tokens` | **仅 Redis** |
|
||||
| `order_commissions` | `partner_bill` 汇总(无明细表) |
|
||||
| `partner_withdrawals` | 手册 v3.1 未包含(后续按需) |
|
||||
| `store_audits` | `common_event(STORE_AUDIT)` |
|
||||
| `after_sale_tickets` | `common_ticket` |
|
||||
| `alerts` | `common_ticket(ALERT)` |
|
||||
| `operation_logs` | `common_event(HQ_OPERATION)` |
|
||||
| `event_logs` | `common_event` + `log_user_analytics` |
|
||||
|
||||
### preV1 扩展字段(已并入 v3.1)
|
||||
|
||||
| 字段 | 表 | 说明 |
|
||||
|------|-----|------|
|
||||
| `device_key` / `phone_verified_at` / `merged_into_user_id` | `user_user` | 访客 JWT + 验机 + 账号合并 |
|
||||
| `client_ip` / `ip_*` / `gps_*` | `user_order` | 下单位置快照 |
|
||||
|
||||
~~以下字段在切换后丢失~~ → **已保留**
|
||||
|
||||
---
|
||||
|
||||
## Prisma Client 调用变更速查
|
||||
|
||||
| 旧调用 | v3.1 调用 |
|
||||
|--------|-----------|
|
||||
| `prisma.product` | `prisma.commonProductItem` |
|
||||
| `prisma.city` | `prisma.commonCity` |
|
||||
| `prisma.cityCommissionRule` | `prisma.commonCityCommissionRule` |
|
||||
| `prisma.storeCategory` | `prisma.commonStoreCategory` |
|
||||
| `prisma.promoCode` | `prisma.commonPromoCode` |
|
||||
| `prisma.orderDelivery` | `prisma.orderDelivery`(表名变 `user_order_delivery`) |
|
||||
| `prisma.benefitCoupon` | `prisma.benefitCoupon`(表 `user_benefit_coupon`) |
|
||||
| `prisma.benefitLedger` | `prisma.commonEvent`(`eventType=BENEFIT_LEDGER`) |
|
||||
| `prisma.redeemToken` | **删除**,改 Redis |
|
||||
| `prisma.storeMedia` | `prisma.commonResource` |
|
||||
| `prisma.storeAudit` | `prisma.commonEvent`(`eventType=STORE_AUDIT`) |
|
||||
| `prisma.payment` | `prisma.logThirdParty` |
|
||||
| `prisma.orderStatusLog` | `prisma.commonEvent`(`eventType=ORDER_STATUS`) |
|
||||
| `prisma.orderItem` | **删除**,读写 `order` 快照字段 |
|
||||
|
||||
> `Partner`、`Store`、`User`、`Order` 等 Model 名保留,仅 `@@map` 物理表名变化。
|
||||
|
||||
---
|
||||
|
||||
## 后端模块影响面
|
||||
|
||||
### P0 — 基础设施
|
||||
|
||||
| 路径 | 影响 | 工作量 |
|
||||
|------|------|--------|
|
||||
| `prisma/schema.prisma` | 已切换为 v3.1 | ✅ |
|
||||
| `prisma/schema.v31.prisma` | 与 `schema.prisma` 同步源 | ✅ |
|
||||
| `prisma/seed-prev1.ts` | 保留为 `seed-legacy`;主 seed 为 `seed-v31.ts` | ✅ |
|
||||
| `prisma/sync-benefit-to-price.ts` | `product` → `commonProductItem` | 低 |
|
||||
| `packages/shared-types` | `ClientApp` 去掉 `USER_H5` 等;新增 Resource/Event 枚举 | 中 |
|
||||
|
||||
### P1 — common 模块(新建)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `src/modules/common/common.module.ts` | **新建** |
|
||||
| `src/modules/common/resource.service.ts` | OSS 凭证、登记、CRUD |
|
||||
| `src/modules/common/resource.controller.ts` | `/common/resources/*` |
|
||||
| `src/modules/common/event.service.ts` | 事件写入/查询/时间线 |
|
||||
| `src/modules/common/ticket.service.ts` | 工单 CRUD |
|
||||
|
||||
### P2 — IAM
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/iam/auth.service.ts` | 去掉访客 `deviceKey` 流程;`user.phone` 必填;`prisma.user` 字段变更 |
|
||||
| `modules/iam/user-address.service.ts` | 表名映射,逻辑基本不变 |
|
||||
| `common/guards/phone-verified.guard.ts` | 适配新 User 模型 |
|
||||
| `common/guards/super-admin.guard.ts` | 无大变 |
|
||||
|
||||
### P3 — catalog
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/catalog/catalog.service.ts` | `city`→`commonCity`,`product`→`commonProductItem`;返回 `coverResource.url` |
|
||||
|
||||
### P4 — trade(改动最大)
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/trade/trade.service.ts` | 下单写 `user_order` 快照(无 `orderItem`);支付写 `log_third_party`;状态变更写 `common_event`;去掉 IP/GPS 字段或扩展 |
|
||||
| `integrations/pay/*` | 回调改查 `log_third_party` |
|
||||
| `jobs/*`(配送 Mock) | `orderDelivery` 字段对齐 |
|
||||
|
||||
### P5 — benefit
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/benefit/benefit.service.ts` | 发券逻辑保留;流水从 `benefitLedger.create` → `commonEvent.create(BENEFIT_LEDGER)`;读明细改查 `commonEvent` |
|
||||
|
||||
### P6 — redeem
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/redeem/redeem.service.ts` | **删除** `redeemToken` DB 写入,仅 Redis;`cityCommissionRule`→`commonCityCommissionRule` |
|
||||
|
||||
### P7 — store
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/store/store.service.ts` | `storeAudit`→`commonEvent`;封面改 `coverResourceId`;`city`→`commonCity` |
|
||||
|
||||
### P8 — settlement
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/settlement/settlement.service.ts` | `partnerBill` 状态枚举变更;去掉 `orderCommission` 明细 |
|
||||
|
||||
### P9 — analytics
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/analytics/analytics.service.ts` | `eventLog`→`logUserAnalytics` |
|
||||
|
||||
### P10 — ops(HQ 后台)
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `modules/ops/admin-stores.service.ts` | `storeMedia`→`commonResource`;`coverUrl`→`coverResourceId`;`city`→`commonCity` |
|
||||
| `modules/ops/admin-benefit.service.ts` | 流水列表改查 `commonEvent` |
|
||||
| `modules/ops/admin-orders.service.ts` | 订单含内嵌商品快照;无 `items` include |
|
||||
| `modules/ops/admin-dashboard.service.ts` | 统计字段:去掉 guest/merged 用户计数 |
|
||||
| `modules/ops/admin-cities.service.ts` | `city`→`commonCity` |
|
||||
| `modules/ops/admin-partners.service.ts` | 订单关联 `city.partnerId` 不变 |
|
||||
| `modules/ops/admin-redeem.service.ts` | 表名映射 |
|
||||
| `modules/ops/admin-users.service.ts` | 去掉 merged/guest 相关 |
|
||||
| `admin-stores.controller.ts` | `/admin/store-media` → `/admin/resources` 或复用 common API |
|
||||
|
||||
---
|
||||
|
||||
## 前端影响面
|
||||
|
||||
| 应用 | 影响 |
|
||||
|------|------|
|
||||
| `apps/h5-user` | 登录流(phone 必填);商品图 URL 来源;订单详情无 items 数组 |
|
||||
| `apps/h5-shop` | 门店详情封面 URL |
|
||||
| `apps/h5-partner` | 录店上传走 `/common/resources` |
|
||||
| `apps/admin-web` | 门店资源页改 `common_resource`;权益流水改 event;订单详情结构调整 |
|
||||
| `packages/shared-types` | 枚举与 DTO 同步 |
|
||||
|
||||
---
|
||||
|
||||
## P1 进度(common 模块)
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| `modules/common/` Resource/Event/Ticket/ThirdPartyLog | ✅ |
|
||||
| `common/event/event.helpers.ts` 权益/订单事件 | ✅ |
|
||||
| 业务层改用 `commonEvent` / `commonResource` / `logThirdParty` | ✅ |
|
||||
| `pnpm run build` | ✅ |
|
||||
|
||||
## P2–P6 进度(业务层 + 兼容层 + 后台)
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| P2 IAM + catalog 适配 v3.1 | ✅ |
|
||||
| P3 trade + benefit(下单/发券/支付日志/事件) | ✅ |
|
||||
| P4 redeem + store(Redis token + 封面资源) | ✅ |
|
||||
| P5 ops 后台(订单/门店/权益/城市/合伙人) | ✅ |
|
||||
| P6 兼容层 `v31-compat.ts`(订单 items、门店 coverUrl、流水/状态日志) | ✅ |
|
||||
| `admin/products` CRUD + HQ 商品页 | ✅ |
|
||||
| `sync-benefit-to-price.ts` → `commonProductItem` | ✅ |
|
||||
| `schema.prisma` ↔ `schema.v31.prisma` 同步 | ✅ |
|
||||
| smoke 脚本 `scripts/smoke-prev1.mjs` | ✅ |
|
||||
|
||||
### 新增 HQ API
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `GET/POST /admin/products` | 商品列表/新建 |
|
||||
| `GET/PUT /admin/products/:id` | 商品详情/更新 |
|
||||
|
||||
### 新增 API(`/api/v1/common/*`)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `POST /common/resources/upload-token` | Mock OSS 直传凭证 |
|
||||
| `POST/GET/PUT/DELETE /common/resources` | 资源 CRUD |
|
||||
| `POST/GET /common/events` | 事件写入/查询 |
|
||||
| `GET /common/events/timeline` | 时间线 |
|
||||
| `POST/GET/PUT /common/tickets` | 工单 |
|
||||
| `GET /common/third-party-logs` | HQ 只读支付/第三方日志 |
|
||||
|
||||
## 建议实施顺序
|
||||
|
||||
```
|
||||
P0 schema 切换 + seed-v31
|
||||
→ P1 common 模块(resource + event)
|
||||
→ P2 IAM + catalog(可登录、可看商品)
|
||||
→ P3 trade + benefit(可下单发券)
|
||||
→ P4 redeem + store(可核销)
|
||||
→ P5 ops 后台 + settlement
|
||||
→ P6 前端对齐 + smoke
|
||||
```
|
||||
|
||||
**冻结规则**:P0~P1 期间不新增业务功能,只修迁移阻塞项。
|
||||
|
||||
---
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `prisma/init_v3.sql` | DDL 源 |
|
||||
| `prisma/schema.v31.prisma` | **新生成**,待激活 |
|
||||
| `prisma/schema.legacy-v21.prisma` | 旧版备份 |
|
||||
| `prisma/schema.prisma` | 当前运行版(**v3.1 已激活**) |
|
||||
@@ -266,7 +266,10 @@ DROP TABLE IF EXISTS user_user;
|
||||
CREATE TABLE user_user (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_no VARCHAR(20) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
device_key VARCHAR(36) DEFAULT NULL COMMENT '访客设备标识',
|
||||
phone VARCHAR(20) DEFAULT NULL COMMENT '验机后必填;访客可为NULL',
|
||||
phone_verified_at DATETIME(3) DEFAULT NULL,
|
||||
merged_into_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '合并入主账号',
|
||||
wx_open_id VARCHAR(64) DEFAULT NULL,
|
||||
wx_union_id VARCHAR(64) DEFAULT NULL,
|
||||
nickname VARCHAR(64) DEFAULT NULL,
|
||||
@@ -281,8 +284,10 @@ CREATE TABLE user_user (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_user_phone (phone),
|
||||
UNIQUE KEY uk_user_user_no (user_no),
|
||||
UNIQUE KEY uk_user_user_device_key (device_key),
|
||||
KEY idx_user_user_source (source_type, source_ref_id),
|
||||
KEY idx_user_user_referrer (referrer_user_id),
|
||||
KEY idx_user_user_merged (merged_into_user_id),
|
||||
KEY idx_user_user_wx_open (wx_open_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='C端用户';
|
||||
|
||||
@@ -426,6 +431,16 @@ CREATE TABLE user_order (
|
||||
receiver_province VARCHAR(32) NOT NULL,
|
||||
receiver_city VARCHAR(32) NOT NULL,
|
||||
receiver_district VARCHAR(32) NOT NULL,
|
||||
client_ip VARCHAR(45) DEFAULT NULL COMMENT '下单时客户端IP',
|
||||
ip_province VARCHAR(32) DEFAULT NULL COMMENT 'IP解析省',
|
||||
ip_city VARCHAR(32) DEFAULT NULL COMMENT 'IP解析市',
|
||||
ip_district VARCHAR(32) DEFAULT NULL COMMENT 'IP解析区县',
|
||||
gps_province VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析省',
|
||||
gps_city VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析市',
|
||||
gps_district VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析区县',
|
||||
gps_latitude DECIMAL(10,7) DEFAULT NULL,
|
||||
gps_longitude DECIMAL(10,7) DEFAULT NULL,
|
||||
gps_address VARCHAR(256) DEFAULT NULL COMMENT 'GPS逆地理地址',
|
||||
pay_external_no VARCHAR(64) DEFAULT NULL COMMENT '微信交易号(冗余)',
|
||||
paid_at DATETIME(3) DEFAULT NULL COMMENT '支付时间',
|
||||
shipped_at DATETIME(3) DEFAULT NULL COMMENT '发货时间(冗余=user_order_delivery.shipping_at)',
|
||||
@@ -442,6 +457,8 @@ CREATE TABLE user_order (
|
||||
KEY idx_user_order_product (product_id),
|
||||
KEY idx_user_order_barcode (barcode_69),
|
||||
KEY idx_user_order_pay_external (pay_external_no),
|
||||
KEY idx_user_order_ip_city (ip_city),
|
||||
KEY idx_user_order_gps_city (gps_city),
|
||||
CONSTRAINT fk_user_order_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_user_order_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_user_order_origin FOREIGN KEY (origin_order_id) REFERENCES user_order(id) ON DELETE SET NULL,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,847 @@
|
||||
// 杜康好客 · V3.1 数据模型(由 init_v3.sql 生成)
|
||||
// 激活方式:确认后替换 schema.prisma,执行 prisma migrate / db push + seed-v31
|
||||
// 旧版备份:schema.legacy-v21.prisma
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── 枚举(与 init_v3.sql COMMENT 对齐)────────────────
|
||||
|
||||
enum ClientApp {
|
||||
USER_MINI
|
||||
USER_H5
|
||||
PARTNER_MINI
|
||||
PARTNER_H5
|
||||
HQ_MINI
|
||||
HQ_WEB
|
||||
SHOP_H5
|
||||
}
|
||||
|
||||
enum ResourceOwnerType {
|
||||
PRODUCT
|
||||
STORE
|
||||
PARTNER
|
||||
USER
|
||||
ORDER
|
||||
PROMO
|
||||
HQ
|
||||
}
|
||||
|
||||
enum ResourceBizType {
|
||||
COVER
|
||||
ENV
|
||||
CONTRACT
|
||||
CAROUSEL
|
||||
DETAIL
|
||||
AVATAR
|
||||
QRCODE
|
||||
SIGN_PHOTO
|
||||
VIDEO
|
||||
}
|
||||
|
||||
enum ResourceMediaType {
|
||||
IMAGE
|
||||
VIDEO
|
||||
FILE
|
||||
}
|
||||
|
||||
enum ResourceStatus {
|
||||
ACTIVE
|
||||
DELETED
|
||||
}
|
||||
|
||||
enum EventType {
|
||||
STORE_AUDIT
|
||||
ORDER_STATUS
|
||||
BENEFIT_LEDGER
|
||||
HQ_OPERATION
|
||||
PROMO_TOUCH
|
||||
}
|
||||
|
||||
enum ActorType {
|
||||
USER
|
||||
STORE
|
||||
PARTNER
|
||||
HQ
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum TicketType {
|
||||
REFUND
|
||||
RESHIPMENT
|
||||
ALERT
|
||||
}
|
||||
|
||||
enum AromaType {
|
||||
QINGXIANG
|
||||
JIANGXIANG
|
||||
NONGXIANG
|
||||
}
|
||||
|
||||
enum ProductStatus {
|
||||
DRAFT
|
||||
ON_SALE
|
||||
OFF_SALE
|
||||
}
|
||||
|
||||
enum PromoCodeStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum CityStatus {
|
||||
PENDING
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum PartnerStaffRole {
|
||||
PARTNER
|
||||
INTERNAL
|
||||
PROMOTER
|
||||
}
|
||||
|
||||
enum AccountStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum HqAdminRole {
|
||||
SUPER_ADMIN
|
||||
OPS
|
||||
FINANCE
|
||||
CUSTOMER_SERVICE
|
||||
}
|
||||
|
||||
enum PartnerBillStatus {
|
||||
DRAFT
|
||||
CONFIRMED
|
||||
PAID
|
||||
}
|
||||
|
||||
enum StoreStatus {
|
||||
OPEN
|
||||
PAUSED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum UserSourceType {
|
||||
ORGANIC
|
||||
PROMO_CODE
|
||||
SHARE_LINK
|
||||
FRIEND_REFERRAL
|
||||
OFFLINE_EVENT
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum OrderType {
|
||||
NORMAL
|
||||
RESHIPMENT
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
PENDING_PAY
|
||||
PENDING_SHIP
|
||||
OUT_WAREHOUSE
|
||||
SHIPPING
|
||||
PENDING_RECEIVE
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
REFUNDING
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
enum PayStatus {
|
||||
UNPAID
|
||||
PAYING
|
||||
PAID
|
||||
REFUNDING
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
enum DeliveryType {
|
||||
LOCAL
|
||||
CROSS_CITY
|
||||
}
|
||||
|
||||
enum FreightPayType {
|
||||
FREE
|
||||
COD
|
||||
}
|
||||
|
||||
enum DeliveryProvider {
|
||||
XFX
|
||||
LOGISTICS
|
||||
MANUAL
|
||||
}
|
||||
|
||||
enum BenefitCouponStatus {
|
||||
ACTIVE
|
||||
USED_UP
|
||||
VOID
|
||||
}
|
||||
|
||||
enum StorePayoutStatus {
|
||||
PENDING
|
||||
PAID
|
||||
}
|
||||
|
||||
enum ThirdPartyProvider {
|
||||
WECHAT_PAY
|
||||
WECHAT_REFUND
|
||||
WECHAT_AUTH
|
||||
WECHAT_MAP
|
||||
XFX
|
||||
SMS
|
||||
LOGISTICS
|
||||
}
|
||||
|
||||
enum ThirdPartyLogStatus {
|
||||
PENDING
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum BenefitLedgerType {
|
||||
GRANT
|
||||
REDEEM
|
||||
REFUND_VOID
|
||||
ADJUST
|
||||
}
|
||||
|
||||
// ─── COMMON ───────────────────────────────────────────
|
||||
|
||||
model CommonWxAppConfig {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
clientApp ClientApp @unique @map("client_app")
|
||||
appId String @map("app_id") @db.VarChar(64)
|
||||
appSecret String @map("app_secret") @db.VarChar(128)
|
||||
mchId String? @map("mch_id") @db.VarChar(32)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@map("common_wx_app_config")
|
||||
}
|
||||
|
||||
model CommonResource {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
ownerType ResourceOwnerType @map("owner_type")
|
||||
ownerId BigInt @map("owner_id") @db.UnsignedBigInt
|
||||
bizType ResourceBizType @map("biz_type")
|
||||
mediaType ResourceMediaType @default(IMAGE) @map("media_type")
|
||||
ossBucket String @map("oss_bucket") @db.VarChar(64)
|
||||
ossKey String @map("oss_key") @db.VarChar(256)
|
||||
url String @db.VarChar(512)
|
||||
fileName String? @map("file_name") @db.VarChar(128)
|
||||
fileSize BigInt? @map("file_size") @db.UnsignedBigInt
|
||||
mimeType String? @map("mime_type") @db.VarChar(64)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
status ResourceStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
productCovers CommonProductItem[] @relation("ProductCover")
|
||||
promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
|
||||
userAvatars User[] @relation("UserAvatar")
|
||||
storeCovers Store[] @relation("StoreCover")
|
||||
orderImages Order[] @relation("OrderProductImage")
|
||||
deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
|
||||
|
||||
@@index([ownerType, ownerId, bizType])
|
||||
@@index([status])
|
||||
@@map("common_resource")
|
||||
}
|
||||
|
||||
model CommonEvent {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
eventType EventType @map("event_type")
|
||||
refType String @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt @map("ref_id") @db.UnsignedBigInt
|
||||
actorType ActorType? @map("actor_type")
|
||||
actorId BigInt? @map("actor_id") @db.UnsignedBigInt
|
||||
status String? @db.VarChar(32)
|
||||
param1 String? @db.VarChar(128)
|
||||
param1Desc String? @map("param1_desc") @db.VarChar(64)
|
||||
param2 String? @db.VarChar(128)
|
||||
param2Desc String? @map("param2_desc") @db.VarChar(64)
|
||||
param3 String? @db.VarChar(128)
|
||||
param3Desc String? @map("param3_desc") @db.VarChar(64)
|
||||
amount1 Decimal? @db.Decimal(10, 2)
|
||||
amount2 Decimal? @db.Decimal(10, 2)
|
||||
remark String? @db.VarChar(512)
|
||||
extraJson Json? @map("extra_json")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([refType, refId, eventType])
|
||||
@@index([eventType, createdAt])
|
||||
@@index([actorType, actorId])
|
||||
@@map("common_event")
|
||||
}
|
||||
|
||||
model CommonTicket {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
ticketNo String @unique @map("ticket_no") @db.VarChar(32)
|
||||
ticketType TicketType @map("ticket_type")
|
||||
status String @default("PENDING") @db.VarChar(32)
|
||||
refType String @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt @map("ref_id") @db.UnsignedBigInt
|
||||
operatorType ActorType? @map("operator_type")
|
||||
operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
|
||||
param1 String? @db.VarChar(128)
|
||||
param1Desc String? @map("param1_desc") @db.VarChar(64)
|
||||
param2 String? @db.VarChar(128)
|
||||
param2Desc String? @map("param2_desc") @db.VarChar(64)
|
||||
param3 String? @db.VarChar(128)
|
||||
param3Desc String? @map("param3_desc") @db.VarChar(64)
|
||||
remark String? @db.VarChar(512)
|
||||
extraJson Json? @map("extra_json")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
|
||||
@@index([refType, refId])
|
||||
@@index([ticketType, status])
|
||||
@@map("common_ticket")
|
||||
}
|
||||
|
||||
model CommonProductItem {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
skuCode String @unique @map("sku_code") @db.VarChar(32)
|
||||
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
|
||||
name String @db.VarChar(128)
|
||||
subtitle String? @db.VarChar(256)
|
||||
aromaType AromaType @map("aroma_type")
|
||||
spec String @db.VarChar(128)
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
|
||||
status ProductStatus @default(DRAFT)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
|
||||
detailContent Json? @map("detail_content")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
||||
orders Order[]
|
||||
|
||||
@@index([status, aromaType])
|
||||
@@map("common_product_item")
|
||||
}
|
||||
|
||||
model CommonStoreCategory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
stores Store[]
|
||||
|
||||
@@map("common_store_category")
|
||||
}
|
||||
|
||||
model CommonPromoCode {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(128)
|
||||
status PromoCodeStatus @default(ACTIVE)
|
||||
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
|
||||
scanCount Int @default(0) @map("scan_count")
|
||||
orderCount Int @default(0) @map("order_count")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
|
||||
attributions UserPromoAttribution[]
|
||||
orders Order[]
|
||||
|
||||
@@map("common_promo_code")
|
||||
}
|
||||
|
||||
model CommonCity {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(16)
|
||||
name String @db.VarChar(64)
|
||||
province String @db.VarChar(32)
|
||||
status CityStatus @default(PENDING)
|
||||
partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
|
||||
localMinQty Int @default(2) @map("local_min_qty")
|
||||
crossMinQty Int @default(6) @map("cross_min_qty")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
|
||||
commissionRule CommonCityCommissionRule?
|
||||
stores Store[]
|
||||
orders Order[]
|
||||
|
||||
@@index([partnerId])
|
||||
@@map("common_city")
|
||||
}
|
||||
|
||||
model CommonCityCommissionRule {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
|
||||
orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
|
||||
redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4)
|
||||
partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
|
||||
storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("common_city_commission_rule")
|
||||
}
|
||||
|
||||
// ─── PARTNER ──────────────────────────────────────────
|
||||
|
||||
model Partner {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
companyName String @map("company_name") @db.VarChar(128)
|
||||
address String @db.VarChar(256)
|
||||
contactPhone String @map("contact_phone") @db.VarChar(20)
|
||||
contractNo String? @map("contract_no") @db.VarChar(64)
|
||||
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
|
||||
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
|
||||
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
cities CommonCity[]
|
||||
accounts PartnerAccount[]
|
||||
stores Store[]
|
||||
bills PartnerBill[]
|
||||
|
||||
@@index([contactPhone])
|
||||
@@map("partner_partner")
|
||||
}
|
||||
|
||||
model PartnerAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(64)
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
|
||||
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
|
||||
staffRole PartnerStaffRole? @map("staff_role")
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
|
||||
children PartnerAccount[] @relation("PartnerAccountHierarchy")
|
||||
|
||||
@@index([partnerId])
|
||||
@@index([parentAccountId])
|
||||
@@index([wxOpenId])
|
||||
@@map("partner_account")
|
||||
}
|
||||
|
||||
model PartnerBill {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
billNo String @unique @map("bill_no") @db.VarChar(32)
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
periodStart DateTime @map("period_start") @db.DateTime(3)
|
||||
periodEnd DateTime @map("period_end") @db.DateTime(3)
|
||||
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
|
||||
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
|
||||
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
|
||||
status PartnerBillStatus @default(DRAFT)
|
||||
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([partnerId, status])
|
||||
@@map("partner_bill")
|
||||
}
|
||||
|
||||
// ─── HQ ───────────────────────────────────────────────
|
||||
|
||||
model HqAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(64)
|
||||
adminRole HqAdminRole @default(OPS) @map("admin_role")
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@map("hq_account")
|
||||
}
|
||||
|
||||
// ─── USER ─────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userNo String @unique @map("user_no") @db.VarChar(20)
|
||||
deviceKey String? @unique @map("device_key") @db.VarChar(36)
|
||||
phone String? @unique @db.VarChar(20)
|
||||
phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3)
|
||||
mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
nickname String? @db.VarChar(64)
|
||||
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
|
||||
status Int @default(1) @db.TinyInt
|
||||
sourceType UserSourceType @default(ORGANIC) @map("source_type")
|
||||
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
|
||||
sourceLabel String? @map("source_label") @db.VarChar(128)
|
||||
referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull)
|
||||
mergedFrom User[] @relation("UserMerge")
|
||||
referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
|
||||
referrers User[] @relation("UserReferrer")
|
||||
avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
|
||||
addresses UserAddress[]
|
||||
cityPreference UserCityPreference?
|
||||
promoTouch UserPromoAttribution?
|
||||
orders Order[]
|
||||
benefitCoupons BenefitCoupon[]
|
||||
redeemRecords RedeemRecord[]
|
||||
|
||||
@@index([sourceType, sourceRefId])
|
||||
@@index([referrerUserId])
|
||||
@@index([mergedIntoUserId])
|
||||
@@index([wxOpenId])
|
||||
@@map("user_user")
|
||||
}
|
||||
|
||||
model UserAddress {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
receiverName String @map("receiver_name") @db.VarChar(32)
|
||||
phone String @db.VarChar(20)
|
||||
province String @db.VarChar(32)
|
||||
city String @db.VarChar(32)
|
||||
district String @db.VarChar(32)
|
||||
detail String @db.VarChar(256)
|
||||
latitude Decimal? @db.Decimal(10, 7)
|
||||
longitude Decimal? @db.Decimal(10, 7)
|
||||
isDefault Int @default(0) @map("is_default") @db.TinyInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("user_address")
|
||||
}
|
||||
|
||||
model UserCityPreference {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
|
||||
selectedCityCode String? @map("selected_city_code") @db.VarChar(16)
|
||||
selectedDistrict String? @map("selected_district") @db.VarChar(32)
|
||||
locateCityCode String? @map("locate_city_code") @db.VarChar(16)
|
||||
locateDistrict String? @map("locate_district") @db.VarChar(32)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_city_preference")
|
||||
}
|
||||
|
||||
model UserPromoAttribution {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
|
||||
promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
|
||||
channelName String @map("channel_name") @db.VarChar(128)
|
||||
firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([promoCodeId])
|
||||
@@map("user_promo_attribution")
|
||||
}
|
||||
|
||||
// ─── STORE ────────────────────────────────────────────
|
||||
|
||||
model Store {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
cityId BigInt @map("city_id") @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
|
||||
name String @db.VarChar(128)
|
||||
phone String @db.VarChar(20)
|
||||
province String @db.VarChar(32)
|
||||
cityName String @map("city_name") @db.VarChar(32)
|
||||
district String @db.VarChar(32)
|
||||
address String @db.VarChar(256)
|
||||
latitude Decimal? @db.Decimal(10, 7)
|
||||
longitude Decimal? @db.Decimal(10, 7)
|
||||
intro String? @db.Text
|
||||
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
|
||||
avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
|
||||
rating Decimal? @db.Decimal(3, 2)
|
||||
tags Json?
|
||||
status StoreStatus @default(PAUSED)
|
||||
openTime String? @map("open_time") @db.VarChar(8)
|
||||
closeTime String? @map("close_time") @db.VarChar(8)
|
||||
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
||||
account StoreAccount?
|
||||
redeemRecords RedeemRecord[]
|
||||
ratings StoreRating[]
|
||||
payouts StorePayout[]
|
||||
|
||||
@@index([cityId, status])
|
||||
@@index([partnerId])
|
||||
@@map("store_store")
|
||||
}
|
||||
|
||||
model StoreAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(64)
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("store_account")
|
||||
}
|
||||
|
||||
// ─── ORDER ────────────────────────────────────────────
|
||||
|
||||
model Order {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderNo String @unique @map("order_no") @db.VarChar(32)
|
||||
orderType OrderType @default(NORMAL) @map("order_type")
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
cityId BigInt @map("city_id") @db.UnsignedBigInt
|
||||
status OrderStatus @default(PENDING_PAY)
|
||||
payStatus PayStatus @default(UNPAID) @map("pay_status")
|
||||
deliveryType DeliveryType @map("delivery_type")
|
||||
originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt
|
||||
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
|
||||
channelSource String? @map("channel_source") @db.VarChar(128)
|
||||
productId BigInt @map("product_id") @db.UnsignedBigInt
|
||||
barcode69 String @map("barcode_69") @db.VarChar(32)
|
||||
productName String @map("product_name") @db.VarChar(128)
|
||||
productSpec String @map("product_spec") @db.VarChar(128)
|
||||
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
|
||||
quantity Int
|
||||
listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
|
||||
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
|
||||
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
|
||||
productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
|
||||
freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
|
||||
freightPayType FreightPayType? @map("freight_pay_type")
|
||||
payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
|
||||
benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2)
|
||||
receiverName String @map("receiver_name") @db.VarChar(32)
|
||||
receiverPhone String @map("receiver_phone") @db.VarChar(20)
|
||||
receiverAddress String @map("receiver_address") @db.Text
|
||||
receiverProvince String @map("receiver_province") @db.VarChar(32)
|
||||
receiverCity String @map("receiver_city") @db.VarChar(32)
|
||||
receiverDistrict String @map("receiver_district") @db.VarChar(32)
|
||||
clientIp String? @map("client_ip") @db.VarChar(45)
|
||||
ipProvince String? @map("ip_province") @db.VarChar(32)
|
||||
ipCity String? @map("ip_city") @db.VarChar(32)
|
||||
ipDistrict String? @map("ip_district") @db.VarChar(32)
|
||||
gpsProvince String? @map("gps_province") @db.VarChar(32)
|
||||
gpsCity String? @map("gps_city") @db.VarChar(32)
|
||||
gpsDistrict String? @map("gps_district") @db.VarChar(32)
|
||||
gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7)
|
||||
gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7)
|
||||
gpsAddress String? @map("gps_address") @db.VarChar(256)
|
||||
payExternalNo String? @map("pay_external_no") @db.VarChar(64)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
|
||||
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
|
||||
remark String? @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
|
||||
originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
|
||||
reshipments Order[] @relation("OrderReshipment")
|
||||
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
|
||||
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
|
||||
delivery OrderDelivery?
|
||||
benefitCoupon BenefitCoupon?
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([cityId, createdAt])
|
||||
@@index([productId])
|
||||
@@index([barcode69])
|
||||
@@index([payExternalNo])
|
||||
@@index([ipCity])
|
||||
@@index([gpsCity])
|
||||
@@map("user_order")
|
||||
}
|
||||
|
||||
model OrderDelivery {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
|
||||
provider DeliveryProvider
|
||||
providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
|
||||
trackingNo String? @map("tracking_no") @db.VarChar(64)
|
||||
outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
|
||||
shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
|
||||
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
|
||||
signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("user_order_delivery")
|
||||
}
|
||||
|
||||
// ─── BENEFIT & REDEEM ─────────────────────────────────
|
||||
|
||||
model BenefitCoupon {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
couponNo String @unique @map("coupon_no") @db.VarChar(32)
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
|
||||
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
|
||||
usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2)
|
||||
balance Decimal @db.Decimal(10, 2)
|
||||
status BenefitCouponStatus @default(ACTIVE)
|
||||
sourceProduct String @map("source_product") @db.VarChar(128)
|
||||
version Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
|
||||
redeemRecords RedeemRecord[]
|
||||
|
||||
@@index([userId, status])
|
||||
@@map("user_benefit_coupon")
|
||||
}
|
||||
|
||||
model RedeemRecord {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||
rating StoreRating?
|
||||
payout StorePayout?
|
||||
|
||||
@@index([storeId, createdAt])
|
||||
@@map("user_redeem_record")
|
||||
}
|
||||
|
||||
model StoreRating {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
serviceScore Int @map("service_score") @db.TinyInt
|
||||
envScore Int @map("env_score") @db.TinyInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade)
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@map("user_store_rating")
|
||||
}
|
||||
|
||||
model StorePayout {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2)
|
||||
payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
|
||||
settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
|
||||
status StorePayoutStatus @default(PENDING)
|
||||
expectedPayAt DateTime @map("expected_pay_at") @db.DateTime(3)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
batchNo String? @map("batch_no") @db.VarChar(32)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([storeId, status])
|
||||
@@map("store_payout")
|
||||
}
|
||||
|
||||
// ─── LOG ──────────────────────────────────────────────
|
||||
|
||||
model LogThirdParty {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
provider ThirdPartyProvider
|
||||
scene String @db.VarChar(64)
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt? @map("ref_id") @db.UnsignedBigInt
|
||||
requestUrl String? @map("request_url") @db.VarChar(512)
|
||||
requestBody Json? @map("request_body")
|
||||
responseBody Json? @map("response_body")
|
||||
externalNo String? @map("external_no") @db.VarChar(128)
|
||||
amount Decimal? @db.Decimal(10, 2)
|
||||
status ThirdPartyLogStatus @default(PENDING)
|
||||
errorMessage String? @map("error_message") @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([refType, refId])
|
||||
@@index([provider, scene, createdAt])
|
||||
@@index([externalNo])
|
||||
@@map("log_third_party")
|
||||
}
|
||||
|
||||
model LogUserAnalytics {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userId BigInt? @map("user_id") @db.UnsignedBigInt
|
||||
sessionId String? @map("session_id") @db.VarChar(64)
|
||||
eventName String @map("event_name") @db.VarChar(64)
|
||||
clientApp ClientApp? @map("client_app")
|
||||
pagePath String? @map("page_path") @db.VarChar(128)
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt? @map("ref_id") @db.UnsignedBigInt
|
||||
keyword String? @db.VarChar(128)
|
||||
sourceType String? @map("source_type") @db.VarChar(32)
|
||||
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
|
||||
extraJson Json? @map("extra_json")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([eventName, createdAt])
|
||||
@@index([sessionId])
|
||||
@@index([refType, refId])
|
||||
@@map("log_user_analytics")
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function createMockResource(
|
||||
ownerType: ResourceOwnerType,
|
||||
ownerId: bigint,
|
||||
bizType: ResourceBizType,
|
||||
url: string,
|
||||
) {
|
||||
return prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType,
|
||||
ownerId,
|
||||
bizType,
|
||||
mediaType: ResourceMediaType.IMAGE,
|
||||
ossBucket: 'mock-dukang',
|
||||
ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`,
|
||||
url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding v3.1 data...');
|
||||
|
||||
await prisma.logUserAnalytics.deleteMany();
|
||||
await prisma.logThirdParty.deleteMany();
|
||||
await prisma.storePayout.deleteMany();
|
||||
await prisma.storeRating.deleteMany();
|
||||
await prisma.redeemRecord.deleteMany();
|
||||
await prisma.benefitCoupon.deleteMany();
|
||||
await prisma.orderDelivery.deleteMany();
|
||||
await prisma.order.deleteMany();
|
||||
await prisma.commonEvent.deleteMany();
|
||||
await prisma.commonTicket.deleteMany();
|
||||
await prisma.userPromoAttribution.deleteMany();
|
||||
await prisma.userCityPreference.deleteMany();
|
||||
await prisma.userAddress.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.storeAccount.deleteMany();
|
||||
await prisma.store.deleteMany();
|
||||
await prisma.partnerBill.deleteMany();
|
||||
await prisma.partnerAccount.deleteMany();
|
||||
await prisma.commonCityCommissionRule.deleteMany();
|
||||
await prisma.commonCity.deleteMany();
|
||||
await prisma.partner.deleteMany();
|
||||
await prisma.commonProductItem.deleteMany();
|
||||
await prisma.commonStoreCategory.deleteMany();
|
||||
await prisma.commonPromoCode.deleteMany();
|
||||
await prisma.commonResource.deleteMany();
|
||||
await prisma.hqAccount.deleteMany();
|
||||
|
||||
const partner = await prisma.partner.create({
|
||||
data: {
|
||||
companyName: '郑州城市合伙人',
|
||||
address: '河南省郑州市金水区',
|
||||
contactPhone: '13700000001',
|
||||
bankAccountName: '郑州合伙人公司',
|
||||
bankAccountNo: '6222021234567890',
|
||||
bankBranch: '工商银行郑州分行',
|
||||
},
|
||||
});
|
||||
|
||||
const city = await prisma.commonCity.create({
|
||||
data: {
|
||||
code: '410100',
|
||||
name: '郑州市',
|
||||
province: '河南省',
|
||||
status: 'ACTIVE',
|
||||
partnerId: partner.id,
|
||||
localMinQty: 2,
|
||||
crossMinQty: 6,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.commonCityCommissionRule.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
orderCommissionRate: 0.05,
|
||||
redeemCommissionRate: 0.03,
|
||||
partnerProfitRate: 0.35,
|
||||
storeSettlementRate: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
const categories = await Promise.all([
|
||||
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
|
||||
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
|
||||
]);
|
||||
|
||||
const productDefs = [
|
||||
{ skuCode: 'QX-001', name: '杜康·白水古酿 500ml', subtitle: '清香型 52度 礼盒装', price: 599, sortOrder: 1, img: 'https://picsum.photos/seed/dukang1/400/400' },
|
||||
{ skuCode: 'QX-002', name: '杜康·年份陈酿(十年)', subtitle: '清香型 42度 纯粮酿造', price: 880, sortOrder: 2, img: 'https://picsum.photos/seed/dukang2/400/400' },
|
||||
{ skuCode: 'QX-003', name: '杜康·御享1号 珍藏版', subtitle: '高端定制 限量发售', price: 1299, sortOrder: 3, img: 'https://picsum.photos/seed/dukang3/400/400' },
|
||||
{ skuCode: 'QX-004', name: '杜康·经典传承', subtitle: '清香型 纯粮固态', price: 399, sortOrder: 4, img: 'https://picsum.photos/seed/dukang4/400/400' },
|
||||
];
|
||||
|
||||
const products = [];
|
||||
for (const [i, def] of productDefs.entries()) {
|
||||
const product = await prisma.commonProductItem.create({
|
||||
data: {
|
||||
skuCode: def.skuCode,
|
||||
barcode69: `69000000000${i + 1}`,
|
||||
name: def.name,
|
||||
subtitle: def.subtitle,
|
||||
aromaType: 'QINGXIANG',
|
||||
spec: def.skuCode === 'QX-002' ? '500ml | 42度' : def.skuCode === 'QX-004' ? '500ml | 46度' : '500ml | 52度',
|
||||
price: def.price,
|
||||
benefitAmount: def.price,
|
||||
status: 'ON_SALE',
|
||||
sortOrder: def.sortOrder,
|
||||
},
|
||||
});
|
||||
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
|
||||
await prisma.commonProductItem.update({
|
||||
where: { id: product.id },
|
||||
data: { coverResourceId: cover.id },
|
||||
});
|
||||
products.push(product);
|
||||
}
|
||||
|
||||
await prisma.partnerAccount.create({
|
||||
data: {
|
||||
partnerId: partner.id,
|
||||
phone: '13700000001',
|
||||
name: '郑州合伙人主账号',
|
||||
isPrimary: 1,
|
||||
staffRole: 'PARTNER',
|
||||
},
|
||||
});
|
||||
|
||||
const storeDefs = [
|
||||
{
|
||||
name: '郑州老城店',
|
||||
phone: '0371-88880001',
|
||||
district: '金水区',
|
||||
address: '花园路100号',
|
||||
intro: '正宗河南菜,欢迎核销好客权益',
|
||||
img: 'https://picsum.photos/seed/store1/400/300',
|
||||
categoryId: categories[0].id,
|
||||
accountPhone: '13900000001',
|
||||
accountName: '老城店店长',
|
||||
},
|
||||
{
|
||||
name: '郑州美食城店',
|
||||
phone: '0371-88880002',
|
||||
district: '二七区',
|
||||
address: '大学路200号',
|
||||
intro: '地方特色餐饮',
|
||||
img: 'https://picsum.photos/seed/store2/400/300',
|
||||
categoryId: categories[1].id,
|
||||
accountPhone: '13900000002',
|
||||
accountName: '美食城店长',
|
||||
},
|
||||
];
|
||||
|
||||
for (const def of storeDefs) {
|
||||
const store = await prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerId: partner.id,
|
||||
categoryId: def.categoryId,
|
||||
name: def.name,
|
||||
phone: def.phone,
|
||||
province: '河南省',
|
||||
cityName: '郑州市',
|
||||
district: def.district,
|
||||
address: def.address,
|
||||
intro: def.intro,
|
||||
status: 'OPEN',
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
bankAccountName: def.name,
|
||||
bankAccountNo: '6222029876543210',
|
||||
bankBranch: '建设银行郑州分行',
|
||||
},
|
||||
});
|
||||
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
|
||||
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||||
await prisma.storeAccount.create({
|
||||
data: { storeId: store.id, phone: def.accountPhone, name: def.accountName },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
userNo: 'DK88293401',
|
||||
phone: '13800000001',
|
||||
phoneVerifiedAt: new Date(),
|
||||
nickname: '测试用户',
|
||||
cityPreference: {
|
||||
create: {
|
||||
selectedCityCode: '410100',
|
||||
selectedDistrict: '郑州市',
|
||||
locateCityCode: '410100',
|
||||
locateDistrict: '金水区',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.hqAccount.create({
|
||||
data: {
|
||||
phone: '13600000001',
|
||||
name: '总部管理员',
|
||||
adminRole: 'SUPER_ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
await prisma.partnerBill.create({
|
||||
data: {
|
||||
billNo: `PB${Date.now()}`,
|
||||
partnerId: partner.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
orderCommission: 1200,
|
||||
redeemCommission: 800,
|
||||
totalAmount: 2000,
|
||||
status: 'CONFIRMED',
|
||||
confirmedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Seed complete:', {
|
||||
city: city.name,
|
||||
products: products.length,
|
||||
stores: storeDefs.length,
|
||||
testPhones: {
|
||||
user: '13800000001',
|
||||
store: '13900000001',
|
||||
partner: '13700000001',
|
||||
hq: '13600000001',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 将 products.benefit_amount 同步为与 price 相同(全额好客权益)。
|
||||
* 将 common_product_item.benefit_amount 同步为与 price 相同(全额好客权益)。
|
||||
* 用法:pnpm db:sync-benefit
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
@@ -7,13 +7,13 @@ import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const products = await prisma.product.findMany({
|
||||
const products = await prisma.commonProductItem.findMany({
|
||||
select: { id: true, skuCode: true, name: true, price: true, benefitAmount: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
if (products.length === 0) {
|
||||
console.log('No products found. Run pnpm db:seed first.');
|
||||
console.log('No products found. Run pnpm prisma:seed first.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ async function main() {
|
||||
const price = Number(p.price);
|
||||
const before = p.benefitAmount != null ? Number(p.benefitAmount) : null;
|
||||
|
||||
await prisma.product.update({
|
||||
await prisma.commonProductItem.update({
|
||||
where: { id: p.id },
|
||||
data: { benefitAmount: p.price },
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { SettlementModule } from './modules/settlement/settlement.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import { JobsModule } from './jobs/jobs.module';
|
||||
import { OpsModule } from './modules/ops/ops.module';
|
||||
import { CommonModule } from './modules/common/common.module';
|
||||
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,6 +40,8 @@ import { OpsModule } from './modules/ops/ops.module';
|
||||
AnalyticsModule,
|
||||
JobsModule,
|
||||
OpsModule,
|
||||
CommonModule,
|
||||
CallbacksModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../integrations/integrations.module';
|
||||
import { TradeModule } from '../modules/trade/trade.module';
|
||||
import { WechatPayCallbackController } from './wechat-pay.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, TradeModule],
|
||||
controllers: [WechatPayCallbackController],
|
||||
})
|
||||
export class CallbacksModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
|
||||
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
|
||||
|
||||
type RawBodyRequest = Request & { body: Buffer };
|
||||
|
||||
@Controller('callbacks/wechat')
|
||||
export class WechatPayCallbackController {
|
||||
constructor(
|
||||
private readonly tradeService: TradeService,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
) {}
|
||||
|
||||
@Post('pay')
|
||||
async payNotify(
|
||||
@Req() req: RawBodyRequest,
|
||||
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
const rawBody =
|
||||
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
|
||||
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
|
||||
const notify = await this.wechat.parsePayNotification(headers, rawBody);
|
||||
await this.tradeService.handlePaySuccess({
|
||||
orderNo: notify.outTradeNo,
|
||||
transactionId: notify.transactionId,
|
||||
amountFen: notify.amountFen,
|
||||
});
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '处理失败';
|
||||
return res.status(500).json({ code: 'FAIL', message });
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user