Files
order_site/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx
T
yml2213 dfc01db4ae feat(admin): 整合财务模块导航为侧边栏子菜单与顶部联动Tab
- 侧边栏收敛:将散落在运营分组下的销售毛利、商品与成本、钱包管理、收支明细整合入「财务」二级子菜单,并支持智能展开与角色权限隔离
- 顶部导航联动:新增 FinanceSubNav 通用组件,在销售毛利、商品与成本中心、钱包管理、收支明细 4 个页面顶部统一提供一键切换 Tab
- 语义规范化:将原“财务”流水页面标题明确为“收支明细”,消除模块与页面的命名歧义
- 验证方式:npm run check 全量检查通过(Prettier、ESLint、tsc、单测 100% 通过),构建产物已同步至运行容器并验证路由与交互
2026-10-25 20:39:11 +08:00

251 lines
8.7 KiB
TypeScript

/**
* 成本中心外壳:来源筛选、总览统计与固定 Tab。
*
* 结构(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,
Segmented,
Space,
Statistic,
Tabs,
Tag,
Typography,
} from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '@/components/admin/PageHeader'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import { usePersistedState } from '@/hooks/usePersistedState'
import { fetchAdminCostCenterOptions, fetchAdminCostCenterOverview } from '@/services/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
import { ChannelMappingPanel } from './ChannelMappingPanel'
import CostConfigPanel from './CostConfigPanel'
import { CostExceptionPanel } from './CostExceptionPanel'
import { ProductCatalogPanel } from './ProductCatalogPanel'
import { CostCenterContext } from './CostCenterContext'
import type { CostScope } from './shared'
const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
{ key: 'all', label: '全部', hint: '不区分来源,查看全部商品、映射与成本。' },
{
key: 'affiliate_dash',
label: '自动发货',
hint: '只按上游货源 SKU 配采购成本,不维护商品目录和渠道映射。',
},
{
key: 'work_order',
label: '接单平台',
hint: '需要打手接单履约的商品;成本以打手报酬为主,按接单模板配价。',
},
{
key: 'manual',
label: '人工客服',
hint: '客服手工处理的商品;成本按内部 SKU 的消耗积分结算。',
},
]
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 isCostScope(value: unknown): value is CostScope {
return SCOPES.some((item) => item.key === value)
}
export default function CostCenterShell() {
const queryClient = useQueryClient()
const canConfig = hasAdminPermission('finance.config')
const [scope, setScope] = usePersistedState<CostScope>(
'admin-cost-center:scope',
'all',
isCostScope,
)
const [activeTab, setActiveTab] = usePersistedState<CostCenterTab>(
'admin-cost-center:tab',
'products',
isCostCenterTab,
)
const overviewQuery = useQuery({
queryKey: ['admin-cost-center-overview', scope],
queryFn: () => fetchAdminCostCenterOverview({ scope }),
})
const optionsQuery = useQuery({
queryKey: ['admin-cost-center-options'],
queryFn: fetchAdminCostCenterOptions,
})
const overview = overviewQuery.data?.data
const options = optionsQuery.data?.data
function refreshAll() {
// 按前缀失效:新增一个 admin-cost-center-* 查询不需要再回来改这里。
void queryClient.invalidateQueries({
predicate: (query) => String(query.queryKey[0] ?? '').startsWith('admin-cost-center'),
})
}
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,
options,
refreshAll,
}}
>
<section className="page-stack">
<FinanceSubNav current="/admin/cost-center" />
<PageHeader
title="商品与成本中心"
description="商品 SKU 先归一;成本在各来源自己的配置里维护(人工 SKU 消耗积分 / 自动发货采购价 / 接单模板发单金额),历史成本靠快照固化。"
extra={
<Button
icon={<ReloadOutlined />}
onClick={refreshAll}
loading={overviewQuery.isFetching}
>
刷新
</Button>
}
/>
<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?.openExceptionCount || 0],
['未映射订单项', overview?.unmappedOrderItemCount || 0],
].map(([label, value]) => (
<Col xs={12} md={8} xl={4} key={label}>
<Card>
<Statistic title={label} value={value as number} />
</Card>
</Col>
))}
</Row>
<Card>
<Space wrap size={12} align="center">
<Segmented
value={scope}
onChange={(value) => setScope(value as CostScope)}
options={SCOPES.map((item) => ({
value: item.key,
label: (
<Space size={4}>
<span>{item.label}</span>
<Tag>
{Number(
item.key === 'all'
? (overview?.productTotalCount ?? overview?.productCount ?? 0)
: (overview?.productSourceCounts?.[item.key] ?? 0),
)}
</Tag>
</Space>
),
}))}
/>
<Typography.Text type="secondary">{scopeHint}</Typography.Text>
</Space>
</Card>
{!canConfig ? (
<Alert
type="info"
showIcon
message="当前账号为只读权限"
description="可以查看商品、映射、成本配置和成本异常;新增/修改需要 finance.config 权限。"
/>
) : null}
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as CostCenterTab)}
items={[
{
key: 'products',
label: '标准商品',
children: <ProductCatalogPanel />,
},
{
key: 'mappings',
label: '渠道映射',
children: mappingsApplicable ? (
<ChannelMappingPanel />
) : (
<Alert
type="info"
showIcon
message={`${scopeLabel}不维护渠道映射`}
description={
scope === 'affiliate_dash'
? '自动发货的订单项直接按 aff:<skuCode> 归一,不经过渠道映射;要核对采购价请到「成本配置 → 自动发货采购价」。'
: '人工客服的订单项按内部 SKU 归一(man: 前缀),不走渠道映射;要配置消耗积分请到「成本配置 → 人工客服 SKU」。'
}
/>
),
},
{
key: 'cost',
label: '成本配置',
children: <CostConfigPanel />,
},
{
key: 'exceptions',
label: (
<span>
成本异常
{Number(overview?.openExceptionCount || 0) > 0 ? (
<Tag color="red" style={{ marginInlineStart: 6 }}>
{Number(overview?.openExceptionCount || 0)}
</Tag>
) : null}
</span>
),
children: <CostExceptionPanel />,
},
]}
/>
</section>
</CostCenterContext.Provider>
)
}