fix(admin): 超管看不到下游订单——新增平台侧跨商户订单视图
问题:管理员首页 Dashboard 显示平台总量(37 单,跨全部商户),点进「发货订单」 却只有 29 单,两个数字互相矛盾且界面上没有任何解释。 根因是「平台管理员」在两套接口里语义不同: - /api/dashboard:isPlatformAdmin 只看 JWT 角色 → 平台视角,跨商户聚合 - /api/merchant/*:Tenant 中间件强制落到单个 merchant_id → 商户成员视角。 管理员唯一的成员关系是自营商户,于是被静默框定在商户 1 且 34 条 /platform/* 路由中没有任何订单入口,前端也没有商户切换器或上下文提示。 修复(不改动商户侧隔离语义): - 新增 GET /api/platform/orders,support 按商户/状态/来源/关键字筛选,缺省为全部商户 - Service 抽出共用的 orderFilters 集中校验,ListOrders 与 ListPlatformOrders 并行; ListOrders 仍强制单商户,避免新增平台路径时顺手放宽商户侧可见范围 - FulfillmentOrder 增加 Merchant 关联字段,仅平台侧 Preload,商户侧响应结构不变 - 前端新增「下游订单(全部商户)」页与菜单项,「发货订单」更名为「发货订单(当前商户)」 - README 补充两个订单入口的说明,避免后来者重踩 验证:单测覆盖跨商户可见性、按商户筛选、筛选校验、以及商户侧隔离不回归; 实测新接口返回 37 单(自营 29 + 测试商户 8)且带商户信息,商户侧仍为 29 单且无 merchant 字段。
This commit is contained in:
@@ -191,6 +191,22 @@ POST /api/open/v1/orders/ship-notify
|
||||
|
||||
管理员登录后可访问商品管理、订单管理、用户管理、发货日志等功能。后台默认地址 `/`。
|
||||
|
||||
### 订单管理有两个入口,不要混淆
|
||||
|
||||
平台管理员同时拥有「平台视角」与「商户视角」两套订单入口,它们的数据范围**不同**:
|
||||
|
||||
| 入口 | 接口 | 数据范围 | 是否受商户上下文影响 |
|
||||
|------|------|----------|----------------------|
|
||||
| 下游订单(全部商户) | `GET /api/platform/orders` | 全部下游商户 | 否 |
|
||||
| 发货订单(当前商户) | `GET /api/merchant/orders` | 仅当前商户 | 是,取决于 `X-Merchant-ID` |
|
||||
|
||||
首页 Dashboard 对管理员是**平台口径**(跨商户聚合)。如果只看「发货订单」,需要先知道
|
||||
自己当前处在哪个商户上下文里,否则会出现「首页 37 单、订单页 29 单」这种看起来像 bug 的差异——
|
||||
实际上订单页只显示了自营商户这一家。
|
||||
|
||||
商户上下文的来源是前端 `localStorage.merchant_id`,只会在「平台商户」页点「成员」时被写入。
|
||||
需要看某个下游商户的订单时,用「下游订单」页的商户筛选即可,不必切换上下文。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/middleware"
|
||||
@@ -25,6 +26,37 @@ func (h *MerchantHandler) ListOrders(c *gin.Context) {
|
||||
response.Page(c, list, total, page, size)
|
||||
}
|
||||
|
||||
// ListPlatformOrders GET /api/platform/orders
|
||||
//
|
||||
// 平台管理员跨商户查看下游订单。merchant_id 查询参数可选:不传表示全部商户。
|
||||
// 与商户侧 /api/merchant/orders 是两条独立路径——商户侧仍强制单商户隔离,
|
||||
// 这里不依赖 X-Merchant-ID,因此管理员遗留的商户上下文不会影响本接口。
|
||||
func (h *MerchantHandler) ListPlatformOrders(c *gin.Context) {
|
||||
page, size := pageParams(c)
|
||||
|
||||
var merchantID *uint
|
||||
if raw := strings.TrimSpace(c.Query("merchant_id")); raw != "" {
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "商户 ID 无效")
|
||||
return
|
||||
}
|
||||
value := uint(id)
|
||||
merchantID = &value
|
||||
}
|
||||
|
||||
list, total, err := h.fulfillmentSvc.ListPlatformOrders(merchantID, page, size, c.Query("order_status"), c.Query("order_source"), c.Query("keyword"))
|
||||
if err != nil {
|
||||
if err.Error() == "无效的订单来源" || err.Error() == "无效的订单状态" {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Page(c, list, total, page, size)
|
||||
}
|
||||
|
||||
type merchantManualOrderReq struct {
|
||||
ClientOrderNo string `json:"client_order_no" binding:"required,max=96"`
|
||||
SKU string `json:"sku" binding:"required,max=96"`
|
||||
|
||||
@@ -246,6 +246,9 @@ type FulfillmentOrder struct {
|
||||
DeliveryLinkGeneratedAt *time.Time `json:"delivery_link_generated_at"`
|
||||
|
||||
MerchantProduct *MerchantProduct `gorm:"foreignKey:MerchantProductID" json:"merchant_product,omitempty"`
|
||||
// Merchant 仅在平台侧跨商户查询时 Preload;商户侧响应里为 nil,被 omitempty 省略,
|
||||
// 因此该字段不改变既有接口结构。
|
||||
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
|
||||
}
|
||||
|
||||
// CallbackSubscription 为商户提供独立、可禁用的事件订阅。
|
||||
|
||||
@@ -284,6 +284,9 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
admin.PATCH("/platform/admins/:id", h.User.UpdatePlatformAdmin)
|
||||
admin.DELETE("/platform/admins/:id", h.User.DeletePlatformAdmin)
|
||||
admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants)
|
||||
// 跨商户订单视图:商户后台的 /api/merchant/orders 强制单商户隔离,
|
||||
// 超管需要一条平台视角入口来查看全部下游订单。
|
||||
admin.GET("/platform/orders", h.Merchant.ListPlatformOrders)
|
||||
admin.POST("/platform/merchants", h.Merchant.CreateMerchant)
|
||||
admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings)
|
||||
admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
// seedPlatformOrders 为两个商户各造若干订单,用于平台侧跨商户查询测试。
|
||||
func seedPlatformOrders(t *testing.T) (*FulfillmentService, uint, uint) {
|
||||
t.Helper()
|
||||
db := newServiceTestDB(t)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
|
||||
merchantA, productA := seedFulfillmentMerchant(t, db, "pf-merchant-a", 0, -1, 100)
|
||||
merchantB, productB := seedFulfillmentMerchant(t, db, "pf-merchant-b", 0, -1, 100)
|
||||
|
||||
orders := []model.FulfillmentOrder{
|
||||
{MerchantID: merchantA, OrderNo: "A-1", ClientOrderNo: "A-C1", MerchantProductID: productA.ID, ProductSKU: productA.SKU, ProductName: "商品A", Amount: 100, OrderStatus: model.OrderStatusPaid, OrderSource: model.OrderSourceAPI},
|
||||
{MerchantID: merchantA, OrderNo: "A-2", ClientOrderNo: "A-C2", MerchantProductID: productA.ID, ProductSKU: productA.SKU, ProductName: "商品A", Amount: 100, OrderStatus: model.OrderStatusDelivered, OrderSource: model.OrderSourceManual},
|
||||
{MerchantID: merchantB, OrderNo: "B-1", ClientOrderNo: "B-C1", MerchantProductID: productB.ID, ProductSKU: productB.SKU, ProductName: "商品B", Amount: 200, OrderStatus: model.OrderStatusPaid, OrderSource: model.OrderSourceAPI},
|
||||
}
|
||||
for i := range orders {
|
||||
if err := db.Create(&orders[i]).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
}
|
||||
return svc, merchantA, merchantB
|
||||
}
|
||||
|
||||
// TestListPlatformOrdersSpansMerchants 是本次修复的核心断言:
|
||||
// 平台视角必须能同时看到全部商户的订单,并带出订单所属商户。
|
||||
func TestListPlatformOrdersSpansMerchants(t *testing.T) {
|
||||
svc, merchantA, merchantB := seedPlatformOrders(t)
|
||||
|
||||
list, total, err := svc.ListPlatformOrders(nil, 1, 20, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("平台侧跨商户查询失败: %v", err)
|
||||
}
|
||||
if total != 3 || len(list) != 3 {
|
||||
t.Fatalf("期望 3 单(A 2 单 + B 1 单),实际 total=%d len=%d", total, len(list))
|
||||
}
|
||||
|
||||
seen := map[uint]int{}
|
||||
for _, order := range list {
|
||||
seen[order.MerchantID]++
|
||||
// 跨商户列表必须能说明每单属于哪个商户,否则管理员无法分辨。
|
||||
if order.Merchant == nil {
|
||||
t.Fatalf("订单 %s 未预加载商户信息", order.OrderNo)
|
||||
}
|
||||
}
|
||||
if seen[merchantA] != 2 || seen[merchantB] != 1 {
|
||||
t.Fatalf("商户分布不符:%v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPlatformOrdersFiltersByMerchant 指定 merchant_id 时只返回该商户。
|
||||
func TestListPlatformOrdersFiltersByMerchant(t *testing.T) {
|
||||
svc, merchantA, _ := seedPlatformOrders(t)
|
||||
|
||||
list, total, err := svc.ListPlatformOrders(&merchantA, 1, 20, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("按商户筛选失败: %v", err)
|
||||
}
|
||||
if total != 2 || len(list) != 2 {
|
||||
t.Fatalf("期望商户 A 的 2 单,实际 total=%d len=%d", total, len(list))
|
||||
}
|
||||
for _, order := range list {
|
||||
if order.MerchantID != merchantA {
|
||||
t.Fatalf("出现了其他商户的订单:merchant_id=%d", order.MerchantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPlatformOrdersAppliesFilters 平台侧同样要支持状态/来源/关键字筛选与非法值校验。
|
||||
func TestListPlatformOrdersAppliesFilters(t *testing.T) {
|
||||
svc, _, _ := seedPlatformOrders(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
source string
|
||||
keyword string
|
||||
wantTotal int64
|
||||
wantErrText string
|
||||
}{
|
||||
{name: "按状态筛选", status: model.OrderStatusPaid, wantTotal: 2},
|
||||
{name: "按来源筛选", source: model.OrderSourceManual, wantTotal: 1},
|
||||
{name: "按订单号关键字", keyword: "B-", wantTotal: 1},
|
||||
{name: "按商户单号关键字", keyword: "A-C", wantTotal: 2},
|
||||
{name: "非法状态", status: "not-a-status", wantErrText: "无效的订单状态"},
|
||||
{name: "非法来源", source: "not-a-source", wantErrText: "无效的订单来源"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
list, total, err := svc.ListPlatformOrders(nil, 1, 20, tc.status, tc.source, tc.keyword)
|
||||
if tc.wantErrText != "" {
|
||||
if err == nil || err.Error() != tc.wantErrText {
|
||||
t.Fatalf("期望错误 %q,实际 %v", tc.wantErrText, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if total != tc.wantTotal || int64(len(list)) != tc.wantTotal {
|
||||
t.Fatalf("期望 %d 单,实际 total=%d len=%d", tc.wantTotal, total, len(list))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOrdersStillIsolatesSingleMerchant 卡住商户侧隔离语义:
|
||||
// 这次修复新增了平台侧路径,但绝不能顺手放宽商户侧的可见范围。
|
||||
// 同时商户侧响应不应带上 Merchant(保持既有接口结构不变)。
|
||||
func TestListOrdersStillIsolatesSingleMerchant(t *testing.T) {
|
||||
svc, merchantA, _ := seedPlatformOrders(t)
|
||||
|
||||
list, total, err := svc.ListOrders(merchantA, 1, 20, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("商户侧查询失败: %v", err)
|
||||
}
|
||||
if total != 2 || len(list) != 2 {
|
||||
t.Fatalf("商户侧必须只看到自己的 2 单,实际 total=%d len=%d", total, len(list))
|
||||
}
|
||||
for _, order := range list {
|
||||
if order.MerchantID != merchantA {
|
||||
t.Fatalf("商户侧串到了其他商户的订单:merchant_id=%d", order.MerchantID)
|
||||
}
|
||||
if order.Merchant != nil {
|
||||
t.Fatal("商户侧不应预加载 Merchant,否则会改变既有响应结构")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,38 +24,86 @@ func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.F
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus, orderSource, keyword string) ([]model.FulfillmentOrder, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
|
||||
if orderStatus != "" {
|
||||
switch orderStatus {
|
||||
// orderFilters 是商户侧与平台侧共用的订单筛选条件。
|
||||
// 集中在一处校验,避免以后新增筛选项时只改了一边。
|
||||
type orderFilters struct {
|
||||
// merchantID 为 nil 表示不限商户(仅平台侧使用);指向 0 时仍会按 merchant_id=0 过滤,
|
||||
// 以保持 ListOrders 的原有语义不变。
|
||||
merchantID *uint
|
||||
orderStatus string
|
||||
orderSource string
|
||||
keyword string
|
||||
}
|
||||
|
||||
func (f orderFilters) apply(tx *gorm.DB) (*gorm.DB, error) {
|
||||
if f.merchantID != nil {
|
||||
tx = tx.Where("merchant_id = ?", *f.merchantID)
|
||||
}
|
||||
if f.orderStatus != "" {
|
||||
switch f.orderStatus {
|
||||
case model.OrderStatusPaid, model.OrderStatusDelivering, model.OrderStatusDelivered, model.OrderStatusShipFailed, model.OrderStatusCancelled:
|
||||
default:
|
||||
return nil, 0, errors.New("无效的订单状态")
|
||||
return nil, errors.New("无效的订单状态")
|
||||
}
|
||||
tx = tx.Where("order_status = ?", orderStatus)
|
||||
tx = tx.Where("order_status = ?", f.orderStatus)
|
||||
}
|
||||
if orderSource != "" {
|
||||
switch orderSource {
|
||||
if f.orderSource != "" {
|
||||
switch f.orderSource {
|
||||
case model.OrderSourceAPI, model.OrderSourceManual, model.OrderSourceTest:
|
||||
tx = tx.Where("order_source = ?", orderSource)
|
||||
tx = tx.Where("order_source = ?", f.orderSource)
|
||||
default:
|
||||
return nil, 0, errors.New("无效的订单来源")
|
||||
return nil, errors.New("无效的订单来源")
|
||||
}
|
||||
}
|
||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||
if keyword := strings.TrimSpace(f.keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
tx = tx.Where("order_no LIKE ? OR client_order_no LIKE ?", like, like)
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) listOrders(f orderFilters, page, size int, withMerchant bool) ([]model.FulfillmentOrder, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx, err := f.apply(s.db.Model(&model.FulfillmentOrder{}))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
query := tx.Preload("MerchantProduct.Product")
|
||||
if withMerchant {
|
||||
// 平台侧跨商户列表需要展示订单属于哪个商户。
|
||||
query = query.Preload("Merchant")
|
||||
}
|
||||
var orders []model.FulfillmentOrder
|
||||
err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error
|
||||
err = query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error
|
||||
return orders, total, err
|
||||
}
|
||||
|
||||
// ListOrders 商户后台订单列表,强制按单个商户隔离。
|
||||
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus, orderSource, keyword string) ([]model.FulfillmentOrder, int64, error) {
|
||||
return s.listOrders(orderFilters{
|
||||
merchantID: &merchantID,
|
||||
orderStatus: orderStatus,
|
||||
orderSource: orderSource,
|
||||
keyword: keyword,
|
||||
}, page, size, false)
|
||||
}
|
||||
|
||||
// ListPlatformOrders 平台侧跨商户订单查询,供超管在管理端查看全部下游订单。
|
||||
// merchantID 为 nil 表示不限商户。这是与 ListOrders 并行的另一条路径,
|
||||
// 刻意不改动商户侧「必须落到单个商户」的隔离语义。
|
||||
func (s *FulfillmentService) ListPlatformOrders(merchantID *uint, page, size int, orderStatus, orderSource, keyword string) ([]model.FulfillmentOrder, int64, error) {
|
||||
return s.listOrders(orderFilters{
|
||||
merchantID: merchantID,
|
||||
orderStatus: orderStatus,
|
||||
orderSource: orderSource,
|
||||
keyword: keyword,
|
||||
}, page, size, true)
|
||||
}
|
||||
|
||||
type OpenOrderQuery struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -19,6 +19,7 @@ const MerchantCenter = lazy(() => import('./pages/MerchantCenter'))
|
||||
const MerchantRecharge = lazy(() => import('./pages/MerchantRecharge'))
|
||||
const AlertSettings = lazy(() => import('./pages/AlertSettings'))
|
||||
const PlatformMerchants = lazy(() => import('./pages/PlatformMerchants'))
|
||||
const PlatformOrders = lazy(() => import('./pages/PlatformOrders'))
|
||||
const PlatformProducts = lazy(() => import('./pages/PlatformProducts'))
|
||||
const PlatformUsers = lazy(() => import('./pages/PlatformUsers'))
|
||||
const PlatformRock = lazy(() => import('./pages/PlatformRock'))
|
||||
@@ -92,6 +93,14 @@ function AppRoutes() {
|
||||
<Route path="merchant-members" element={<MerchantPermissionRoute permission="members:manage"><MerchantCenter fixedTab="members" title="成员" /></MerchantPermissionRoute>} />
|
||||
<Route path="merchant-callbacks" element={<MerchantPermissionRoute permission="callbacks:manage"><MerchantCenter fixedTab="callbacks" title="回调" /></MerchantPermissionRoute>} />
|
||||
<Route path="merchant-api-keys" element={<MerchantPermissionRoute permission="api:manage"><MerchantCenter fixedTab="api" title="API 密钥" /></MerchantPermissionRoute>} />
|
||||
<Route
|
||||
path="platform-orders"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<PlatformOrders />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="platform-merchants"
|
||||
element={
|
||||
|
||||
@@ -207,6 +207,9 @@ export const platformApi = {
|
||||
request.delete(`/platform/merchant-accounts/${userId}`).then((r) => r.data.data),
|
||||
merchants: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult<Merchant>),
|
||||
// 平台侧跨商户订单视图:不传 merchant_id 即全部商户
|
||||
orders: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
|
||||
createMerchant: (data: {
|
||||
code: string
|
||||
name: string
|
||||
|
||||
@@ -88,7 +88,10 @@ const adminSections: SidebarSection[] = [
|
||||
key: 'orders',
|
||||
label: '订单管理',
|
||||
icon: <OrderedListOutlined />,
|
||||
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
|
||||
children: [
|
||||
{ key: 'platform-orders', label: '下游订单(全部商户)', path: '/platform-orders' },
|
||||
{ key: 'fulfillment-orders', label: '发货订单(当前商户)', path: '/merchant-orders' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Col, Empty, Input, Row, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { platformApi } from '../api'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import type { FulfillmentOrder, Merchant, PageResult } from '../types'
|
||||
import { formatDateTime } from '../utils/time'
|
||||
import { money, orderStatusMap, orderStatusOptions, pageConfig } from './merchantCenterUtils'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const sourceOptions = [
|
||||
{ value: '', label: '全部来源' },
|
||||
{ value: 'api', label: '开放接口' },
|
||||
{ value: 'manual', label: '人工下单' },
|
||||
{ value: 'test', label: '联调测试' },
|
||||
]
|
||||
|
||||
const sourceText: Record<string, string> = {
|
||||
api: '开放接口',
|
||||
manual: '人工下单',
|
||||
test: '联调测试',
|
||||
}
|
||||
|
||||
interface OrderFilters {
|
||||
merchantId?: number
|
||||
orderStatus?: string
|
||||
orderSource?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
const emptyPage: PageResult<FulfillmentOrder> = { list: [], total: 0, page: 1, size: 20 }
|
||||
|
||||
function orderStatusTag(value: string) {
|
||||
const item = orderStatusMap[value] || { color: 'default', text: value }
|
||||
return <Tag color={item.color}>{item.text}</Tag>
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台侧「下游订单」页。
|
||||
*
|
||||
* 存在的理由:商户后台的「发货订单」走 /api/merchant/orders,服务端强制按单个商户隔离,
|
||||
* 而管理员的默认商户上下文是自营商户——于是首页 Dashboard 显示平台总量(跨商户),
|
||||
* 订单页却只显示自营商户的量,两个数字对不上且无法解释。本页走 /api/platform/orders,
|
||||
* 不依赖 X-Merchant-ID,管理员可以在这里看到全部下游商户的订单。
|
||||
*/
|
||||
export default function PlatformOrders() {
|
||||
const [data, setData] = useState<PageResult<FulfillmentOrder>>(emptyPage)
|
||||
const [merchants, setMerchants] = useState<Merchant[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const [size, setSize] = useState(20)
|
||||
// 已提交的筛选条件即唯一数据源,由 effect 驱动查询;
|
||||
// 关键字另存草稿,避免每敲一个字就打一次接口。
|
||||
const [filters, setFilters] = useState<OrderFilters>({})
|
||||
const [keywordDraft, setKeywordDraft] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const params: Record<string, unknown> = { page, size }
|
||||
if (filters.merchantId) params.merchant_id = filters.merchantId
|
||||
if (filters.orderStatus) params.order_status = filters.orderStatus
|
||||
if (filters.orderSource) params.order_source = filters.orderSource
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
setData(await platformApi.orders(params))
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载下游订单失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, size, filters])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
platformApi
|
||||
.merchants({ page: 1, size: 200 })
|
||||
.then((res) => setMerchants(res.list || []))
|
||||
.catch(() => setMerchants([]))
|
||||
}, [])
|
||||
|
||||
// 改筛选条件时回到第一页,避免停留在越界页码上看到空列表。
|
||||
const updateFilters = useCallback((patch: Partial<OrderFilters>) => {
|
||||
setPage(1)
|
||||
setFilters((prev) => ({ ...prev, ...patch }))
|
||||
}, [])
|
||||
|
||||
const submitKeyword = useCallback(() => {
|
||||
updateFilters({ keyword: keywordDraft.trim() || undefined })
|
||||
}, [keywordDraft, updateFilters])
|
||||
|
||||
const columns: ColumnsType<FulfillmentOrder> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '所属商户',
|
||||
dataIndex: 'merchant_id',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{record.merchant?.name || `商户 #${record.merchant_id}`}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{record.merchant?.code || '-'}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单号 / 商户单号',
|
||||
dataIndex: 'order_no',
|
||||
width: 260,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text copyable={{ text: record.order_no }} strong style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{record.order_no}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||
{record.client_order_no || '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'product_name',
|
||||
width: 190,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{record.product_name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{record.product_sku}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (value) => money(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'order_status',
|
||||
width: 100,
|
||||
render: (value) => orderStatusTag(value),
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'order_source',
|
||||
width: 100,
|
||||
render: (value) => <Tag>{sourceText[value] || value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 175,
|
||||
render: formatDateTime,
|
||||
},
|
||||
{
|
||||
title: '上游单号',
|
||||
dataIndex: 'provider_order_no',
|
||||
width: 200,
|
||||
render: (value) => value
|
||||
? <Text copyable={{ text: value }} style={{ fontFamily: 'monospace', fontSize: 12 }}>{value}</Text>
|
||||
: <Text type="secondary">-</Text>,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="下游订单"
|
||||
subtitle="平台视角:跨全部下游商户,不随当前商户上下文变化。商户后台的「发货订单」只显示单个商户。"
|
||||
breadcrumbs={[{ title: '下游订单' }]}
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void load()}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card style={{ marginBottom: 16, borderRadius: 10, border: '1px solid #e2e8f0' }} styles={{ body: { padding: '16px 20px' } }}>
|
||||
<Row gutter={[12, 12]} align="middle">
|
||||
<Col xs={24} md={6}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder="全部商户"
|
||||
value={filters.merchantId}
|
||||
onChange={(value) => updateFilters({ merchantId: value })}
|
||||
options={merchants.map((item) => ({ value: item.id, label: `${item.name}(${item.code})` }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={filters.orderStatus || ''}
|
||||
onChange={(value) => updateFilters({ orderStatus: value || undefined })}
|
||||
options={orderStatusOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.value === '' ? '全部状态' : item.label,
|
||||
}))}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={5}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={filters.orderSource || ''}
|
||||
onChange={(value) => updateFilters({ orderSource: value || undefined })}
|
||||
options={sourceOptions}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={7}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="订单号 / 商户单号"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={keywordDraft}
|
||||
onChange={(e) => setKeywordDraft(e.target.value)}
|
||||
onPressEnter={submitKeyword}
|
||||
/>
|
||||
<Button type="primary" onClick={submitKeyword}>查询</Button>
|
||||
</Space.Compact>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card style={{ borderRadius: 10, border: '1px solid #e2e8f0' }} styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data.list}
|
||||
locale={{ emptyText: <Empty description="没有符合条件的下游订单" /> }}
|
||||
scroll={{ x: 1370 }}
|
||||
pagination={pageConfig(data, (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setSize(nextSize)
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,9 @@ export interface Merchant {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 平台侧跨商户订单列表里标识订单归属所需的商户信息(后端 Preload 的 Merchant 子集)
|
||||
export type MerchantSummary = Pick<Merchant, 'id' | 'code' | 'name' | 'status'>
|
||||
|
||||
export interface MerchantMember {
|
||||
id: number
|
||||
merchant_id: number
|
||||
@@ -132,6 +135,9 @@ export interface WalletLedgerEntry {
|
||||
|
||||
export interface FulfillmentOrder {
|
||||
id: number
|
||||
merchant_id: number
|
||||
// merchant 仅平台侧跨商户列表会返回;商户侧响应不含该字段(后端 omitempty)
|
||||
merchant?: MerchantSummary | null
|
||||
order_no: string
|
||||
client_order_no: string
|
||||
product_sku: string
|
||||
|
||||
Reference in New Issue
Block a user