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,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>