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:
@@ -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