refactor(admin-ui): 成本中心导航收敛为 4 个固定 Tab

原结构是「4 个来源 Tab(内容只有一句提示语)× 6 个内容 Tab」,
且来源 Tab 会增删内容 Tab:选「自动发货」后标准商品/渠道映射/发单价格直接消失,
切换来源还会强制跳转。用户反馈「逻辑不顺、很多 tab 没必要」。

- 来源改为工具栏 Segmented:只筛数据,不改变导航;Tab 永远 4 个
  标准商品 / 渠道映射 / 成本配置 / 成本异常;
- 自动发货 SKU、发单价格、人工 SKU 配置合并为「成本配置」,
  选「全部」时给三个入口卡片,选具体来源时直接渲染对应面板;
- 自动发货 SKU 原本是标准商品的同数据子集(同一个接口 + carrierType),
  合并后不再有重复入口;
- 渠道映射在自动发货/人工来源下改为说明性提示,不再展示空表;
- 异常徽标改用当前来源的计数,不再出现「徽标 10 万、列表几百条」;
- refreshAll 改为按 queryKey 前缀失效,修好漏刷
  (诊断、未归一队列、人工 SKU 等 5 个查询);
- 删除 Context 里 12 个从未被读取的字段(products/versions/exceptions 等
  空数组与 loading 常量),注释改为与实现一致;
- 「生效发单价格」改名「生效成本版本」——它统计的是全部 active 版本,
  按来源过滤后叫发单价格会误导。

验证:后端 786 测试、前端 lint/typecheck/test/build 通过。
This commit is contained in:
yml2213
2026-10-25 13:55:36 +08:00
parent 88ba87efff
commit 55a3e0df9c
6 changed files with 188 additions and 155 deletions
@@ -1,13 +1,14 @@
/**
* 成本中心页面入口。
*
* 实现已拆分到 `pages/admin/cost-center/` 下的各 Panel:
* - CostCenterShell.tsx 页面骨架、顶部来源切换、Tab 定义
* 页面结构(2026-10-25 收敛为 4 个固定 Tab):
* - CostCenterShell.tsx 页面骨架:来源筛选(Segmented)、统计卡、4 个 Tab
* - ProductCatalogPanel.tsx 标准商品
* - ChannelMappingPanel.tsx 渠道映射
* - AffiliateSkuPanel.tsx 自动发货 SKU
* - ManualSkuConfigPanel.tsx 人工 SKU 配置(消耗积分即人工成本)
* - WorkOrderPricePanel.tsx 发单价格(只读核对接单模板发单金额)
* - ChannelMappingPanel.tsx 渠道映射(映射目录 + 未归一队列)
* - CostConfigPanel.tsx 成本配置:按来源切换
* ├ AffiliateSkuPanel 自动发货采购价
* ├ WorkOrderPricePanel 接单平台发单金额(只读)
* └ ManualSkuConfigPanel 人工客服 SKU 消耗
* - CostExceptionPanel.tsx 成本异常(按原因聚合 + 近 24 小时默认)
* - CostPreviewModal.tsx 成本解析弹窗(异常行 / 订单详情进入)
*/
@@ -1,41 +1,27 @@
/**
* 成本中心页面上下文。
*
* 面板只读这里的查询结果与动作,不自己发请求,避免同一页多套口径。
* 只存放「所有面板都要知道」的三件事:权限、当前来源筛选、刷新动作。
* 具体数据由各面板自己用 react-query 取——曾经这里塞过 products / versions /
* exceptions 等空数组当作「统一数据源」,实际没人读,已删除。
*/
import { createContext, useContext } from 'react'
import type {
ChannelProductMapping,
CostException,
CostItem,
CostModel,
CostPreview,
CostVersion,
ProductCatalogItem,
ProductSource,
} from '@/types/admin'
import type { CostItem, CostModel, CostPreview, ProductSource } from '@/types/admin'
import type { CostScope } from './shared'
export type CostCenterContextValue = {
canConfig: boolean
/** 当前来源筛选:all / affiliate_dash / work_order / manual。 */
scope: CostScope
setScope: (scope: CostScope) => void
scopeLabel: string
/** 商品/映射按来源前缀筛选;成本项/版本/异常按履约方式筛选。 */
/** 商品/映射按 canonical_sku 前缀筛选。 */
productSourceFilter?: ProductSource
/** 成本项/版本/异常按履约方式筛选。 */
fulfillmentModeFilter?: 'affiliate_dash' | 'work_order' | 'manual'
overview?: {
productCount: number
mappingCount: number
costItemCount: number
activeVersionCount: number
openExceptionCount: number
unmappedOrderItemCount: number
productSourceCounts?: Record<string, number>
costModelCounts?: Record<string, number>
}
options?: {
providers: string[]
products: Array<{
@@ -61,26 +47,7 @@ export type CostCenterContextValue = {
}>
}
products: ProductCatalogItem[]
mappingGroups: Array<{
canonicalProductId: number
canonicalSku: string
canonicalName: string
category: string
unitName: string
status: 'active' | 'archived'
mappings: ChannelProductMapping[]
}>
costItems: CostItem[]
versions: CostVersion[]
exceptions: CostException[]
productsLoading: boolean
mappingsLoading: boolean
costItemsLoading: boolean
versionsLoading: boolean
exceptionsLoading: boolean
/** 全量刷新:按 queryKey 前缀失效,避免漏刷新增的查询。 */
refreshAll: () => void
}
@@ -1,34 +1,43 @@
/**
* 成本中心外壳:顶部来源切换、总览统计与 Tab 定义。
* 成本中心外壳:来源筛选、总览统计与固定 Tab。
*
* 各 Tab 由独立 Panel 组件承载,自己取数、自己维护表单与弹窗,
* 避免单文件堆积成不可维护的巨型页面。
* 结构(2026-10-25 调整):
* 1. 来源不再是「假 Tab」,改为工具栏 Segmented —— 只筛数据,不改变导航;
* 2. Tab 固定 4 个,不再随来源增减:标准商品 / 渠道映射 / 成本配置 / 成本异常;
* 3. 原来分散的「自动发货 SKU / 人工 SKU 配置 / 发单价格」合并为「成本配置」;
* 4. 统计卡按当前来源收敛(overview 接口传 scope),和下面的列表对得上;
* 5. 刷新按 queryKey 前缀失效,新增查询不会再漏刷。
*/
import { ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Card, Col, Row, Statistic, Tabs, Tag, Typography } from 'antd'
import {
Alert,
Button,
Card,
Col,
Row,
Segmented,
Space,
Statistic,
Tabs,
Tag,
Typography,
} from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import { usePersistedState } from '@/hooks/usePersistedState'
import { fetchAdminCostCenterOptions, fetchAdminCostCenterOverview } from '@/services/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
import { AffiliateSkuPanel } from './AffiliateSkuPanel'
import { ChannelMappingPanel } from './ChannelMappingPanel'
import CostConfigPanel from './CostConfigPanel'
import { CostExceptionPanel } from './CostExceptionPanel'
import { ManualSkuConfigPanel } from './ManualSkuConfigPanel'
import { ProductCatalogPanel } from './ProductCatalogPanel'
import { WorkOrderPricePanel } from './WorkOrderPricePanel'
import { CostCenterContext } from './CostCenterContext'
import type { CostScope } from './shared'
const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
{
key: 'all',
label: '全部',
hint: '不区分来源,查看全部商品、映射、成本项与版本。',
},
{ key: 'all', label: '全部', hint: '不区分来源,查看全部商品、映射与成本。' },
{
key: 'affiliate_dash',
label: '自动发货',
@@ -37,30 +46,24 @@ const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
{
key: 'work_order',
label: '接单平台',
hint: '需要打手接单履约的商品;成本以打手报酬为主,可按接单规则或商品配价。',
hint: '需要打手接单履约的商品;成本以打手报酬为主,按接单模板配价。',
},
{
key: 'manual',
label: '人工客服',
hint: '客服手工处理的商品;成本按人工实际支出逐单登记。',
hint: '客服手工处理的商品;成本按内部 SKU 的消耗积分结算。',
},
]
const SCOPE_TAG_COLORS: Record<CostScope, string> = {
all: 'default',
affiliate_dash: 'green',
work_order: 'blue',
manual: 'orange',
const TABS = ['products', 'mappings', 'cost', 'exceptions'] as const
type CostCenterTab = (typeof TABS)[number]
function isCostCenterTab(value: unknown): value is CostCenterTab {
return typeof value === 'string' && (TABS as readonly string[]).includes(value)
}
function tabKeysForScope(scope: CostScope) {
return [
...(scope === 'all' || scope === 'work_order' ? ['products', 'mappings'] : []),
...(scope === 'all' || scope === 'affiliate_dash' ? ['affiliate-skus'] : []),
...(scope === 'all' || scope === 'manual' ? ['manual-skus'] : []),
...(scope === 'all' || scope === 'work_order' ? ['prices'] : []),
'exceptions',
]
function isCostScope(value: unknown): value is CostScope {
return SCOPES.some((item) => item.key === value)
}
export default function CostCenterShell() {
@@ -70,13 +73,17 @@ export default function CostCenterShell() {
const [scope, setScope] = usePersistedState<CostScope>(
'admin-cost-center:scope',
'all',
(value): value is CostScope => SCOPES.some((item) => item.key === value),
isCostScope,
)
const [activeTab, setActiveTab] = usePersistedState<CostCenterTab>(
'admin-cost-center:tab',
'products',
isCostCenterTab,
)
const [activeTab, setActiveTab] = usePersistedState<string>('admin-cost-center:tab', 'products')
const overviewQuery = useQuery({
queryKey: ['admin-cost-center-overview'],
queryFn: fetchAdminCostCenterOverview,
queryKey: ['admin-cost-center-overview', scope],
queryFn: () => fetchAdminCostCenterOverview({ scope }),
})
const optionsQuery = useQuery({
queryKey: ['admin-cost-center-options'],
@@ -85,50 +92,31 @@ export default function CostCenterShell() {
const overview = overviewQuery.data?.data
const options = optionsQuery.data?.data
const visibleTabKeys = tabKeysForScope(scope)
useEffect(() => {
if (!visibleTabKeys.includes(activeTab)) {
setActiveTab(visibleTabKeys[0] ?? 'exceptions')
}
}, [activeTab, setActiveTab, visibleTabKeys])
function refreshAll() {
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mappings'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-versions'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-exceptions'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-exceptions-summary'] })
// 按前缀失效:新增一个 admin-cost-center-* 查询不需要再回来改这里。
void queryClient.invalidateQueries({
predicate: (query) => String(query.queryKey[0] ?? '').startsWith('admin-cost-center'),
})
}
const scopeLabel =
scope === 'all' ? '全部' : (SCOPES.find((item) => item.key === scope)?.label ?? '')
const scopeLabel = SCOPES.find((item) => item.key === scope)?.label ?? '全部'
const scopeHint = SCOPES.find((item) => item.key === scope)?.hint ?? ''
const productSourceFilter = scope === 'all' ? undefined : scope
const fulfillmentModeFilter =
scope === 'all' ? undefined : (scope as 'affiliate_dash' | 'work_order' | 'manual')
const mappingsApplicable = scope === 'all' || scope === 'work_order'
return (
<CostCenterContext.Provider
value={{
canConfig,
scope,
setScope,
scopeLabel,
productSourceFilter,
fulfillmentModeFilter,
overview,
options,
products: [],
mappingGroups: [],
costItems: [],
versions: [],
exceptions: [],
productsLoading: false,
mappingsLoading: false,
costItemsLoading: false,
versionsLoading: false,
exceptionsLoading: false,
refreshAll,
}}
>
@@ -147,12 +135,20 @@ export default function CostCenterShell() {
}
/>
<Space size={6}>
<Typography.Text type="secondary">统计口径</Typography.Text>
<Tag color={scope === 'all' ? 'default' : 'blue'}>{scopeLabel}</Tag>
{scope !== 'all' ? (
<Typography.Text type="secondary">(跟随上方来源筛选变化)</Typography.Text>
) : null}
</Space>
<Row gutter={[16, 16]}>
{[
['标准商品', overview?.productCount || 0],
['渠道映射', overview?.mappingCount || 0],
['系统成本项', overview?.costItemCount || 0],
['生效发单价格', overview?.activeVersionCount || 0],
['生效成本版本', overview?.activeVersionCount || 0],
['待处理异常', overview?.openExceptionCount || 0],
['未映射订单项', overview?.unmappedOrderItemCount || 0],
].map(([label, value]) => (
@@ -164,37 +160,29 @@ export default function CostCenterShell() {
))}
</Row>
<Card className="cost-center-carrier-card">
<Tabs
activeKey={scope}
onChange={(key) => {
const next = key as CostScope
setScope(next)
setActiveTab(
next === 'all' || next === 'work_order'
? 'products'
: next === 'affiliate_dash'
? 'affiliate-skus'
: 'manual-skus',
)
}}
items={SCOPES.map((item) => {
const productCount =
item.key === 'all'
? overview?.productCount
: overview?.productSourceCounts?.[item.key]
return {
key: item.key,
<Card>
<Space wrap size={12} align="center">
<Segmented
value={scope}
onChange={(value) => setScope(value as CostScope)}
options={SCOPES.map((item) => ({
value: item.key,
label: (
<span className="cost-center-scope-tab">
<Space size={4}>
<span>{item.label}</span>
<Tag color={SCOPE_TAG_COLORS[item.key]}>{Number(productCount || 0)} 商品</Tag>
</span>
<Tag>
{Number(
item.key === 'all'
? (overview?.productTotalCount ?? overview?.productCount ?? 0)
: (overview?.productSourceCounts?.[item.key] ?? 0),
)}
</Tag>
</Space>
),
children: <Typography.Text type="secondary">{item.hint}</Typography.Text>,
}
})}
/>
}))}
/>
<Typography.Text type="secondary">{scopeHint}</Typography.Text>
</Space>
</Card>
{!canConfig ? (
@@ -202,13 +190,13 @@ export default function CostCenterShell() {
type="info"
showIcon
message="当前账号为只读权限"
description="可以查看商品、映射、发单价格和成本异常;新增/修改需要 finance.config 权限。"
description="可以查看商品、映射、成本配置和成本异常;新增/修改需要 finance.config 权限。"
/>
) : null}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
onChange={(key) => setActiveTab(key as CostCenterTab)}
items={[
{
key: 'products',
@@ -218,22 +206,25 @@ export default function CostCenterShell() {
{
key: 'mappings',
label: '渠道映射',
children: <ChannelMappingPanel />,
children: mappingsApplicable ? (
<ChannelMappingPanel />
) : (
<Alert
type="info"
showIcon
message={`${scopeLabel}不维护渠道映射`}
description={
scope === 'affiliate_dash'
? '自动发货的订单项直接按 aff:<skuCode> 归一,不经过渠道映射;要核对采购价请到「成本配置 → 自动发货采购价」。'
: '人工客服的订单项按内部 SKU 归一(man: 前缀),不走渠道映射;要配置消耗积分请到「成本配置 → 人工客服 SKU」。'
}
/>
),
},
{
key: 'affiliate-skus',
label: '自动发货 SKU',
children: <AffiliateSkuPanel />,
},
{
key: 'manual-skus',
label: '人工 SKU 配置',
children: <ManualSkuConfigPanel />,
},
{
key: 'prices',
label: '发单价格',
children: <WorkOrderPricePanel />,
key: 'cost',
label: '成本配置',
children: <CostConfigPanel />,
},
{
key: 'exceptions',
@@ -249,7 +240,7 @@ export default function CostCenterShell() {
),
children: <CostExceptionPanel />,
},
].filter((item) => visibleTabKeys.includes(item.key))}
]}
/>
</section>
</CostCenterContext.Provider>
@@ -0,0 +1,73 @@
/**
* 成本配置:按来源展示「成本怎么算」。
*
* 三个来源的成本形态完全不同,所以这里不做统一表格,而是按来源切换:
* 自动发货 → 上游 SKU 采购价(按 aff:<skuCode> 归一)
* 接单平台 → 接单模板发单金额(只读核对,改价去接单模板页)
* 人工客服 → 内部 SKU 的履约渠道与消耗积分
*
* 来源在页面顶部工具栏统一选择;选「全部」时这里给出三个入口,
* 避免把三套配置竖着堆在一页里。
*/
import { Button, Card, Col, Row, Space, Typography } from 'antd'
import { AffiliateSkuPanel } from './AffiliateSkuPanel'
import { useCostCenter } from './CostCenterContext'
import { ManualSkuConfigPanel } from './ManualSkuConfigPanel'
import { WorkOrderPricePanel } from './WorkOrderPricePanel'
import type { CostScope } from './shared'
const CONFIG_ENTRIES: Array<{
key: Exclude<CostScope, 'all'>
title: string
description: string
}> = [
{
key: 'affiliate_dash',
title: '自动发货采购价',
description: '按上游货源 SKU 配采购成本,订单项直接按 aff:<skuCode> 归一,不走渠道映射。',
},
{
key: 'work_order',
title: '接单平台发单金额',
description: '接单模板 → 发单金额的当前生效版本,只读核对;改价请改模板后重新同步。',
},
{
key: 'manual',
title: '人工客服 SKU 消耗',
description: '内部 SKU 的履约渠道与消耗积分;积分填 0 表示成本不固定,确认履约时手填。',
},
]
export default function CostConfigPanel() {
const { scope, setScope } = useCostCenter()
if (scope === 'affiliate_dash') return <AffiliateSkuPanel />
if (scope === 'work_order') return <WorkOrderPricePanel />
if (scope === 'manual') return <ManualSkuConfigPanel />
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Typography.Text type="secondary">
成本配置按来源分开维护,选择一个来源查看;也可以在页面顶部直接切换来源。
</Typography.Text>
<Row gutter={[12, 12]}>
{CONFIG_ENTRIES.map((entry) => (
<Col xs={24} xl={8} key={entry.key}>
<Card
size="small"
title={entry.title}
extra={
<Button size="small" type="primary" onClick={() => setScope(entry.key)}>
去配置
</Button>
}
>
<Typography.Text type="secondary">{entry.description}</Typography.Text>
</Card>
</Col>
))}
</Row>
</Space>
)
}
+1 -1
View File
@@ -127,7 +127,7 @@
| --- | --- | --- |
| [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL |
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`107`;履约回填;迁移基线与本地库同步;索引与性能 |
| [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 8 面板) |
| [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) |
| [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 确认履约流程/权限、客服备用金台账、本人账户与编号规则 |
| [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(总账未实施) | 复式记账、总账表、对账、关账 |
| [影子总账说明.md](影子总账说明.md) | 现行说明(未实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 |
+2 -1
View File
@@ -276,7 +276,8 @@ AND (
| `pages/admin/cost-center/CostCenterContext.ts` | 页面上下文(`useCostCenter`) |
| `pages/admin/cost-center/ProductCatalogPanel.tsx` | 标准商品 Tab |
| `pages/admin/cost-center/ChannelMappingPanel.tsx` | 渠道映射 Tab |
| `pages/admin/cost-center/AffiliateSkuPanel.tsx` | 自动发货 SKU Tab |
| `pages/admin/cost-center/CostConfigPanel.tsx` | 成本配置 Tab(按来源切换下面三个) |
| `pages/admin/cost-center/AffiliateSkuPanel.tsx` | 自动发货采购价(成本配置内) |
| `pages/admin/cost-center/CostItemPanel.tsx` | 成本项 Tab |
| `pages/admin/cost-center/CostVersionPanel.tsx` | 成本版本 Tab |
| `pages/admin/cost-center/CostExceptionPanel.tsx` | 成本异常 Tab |