feat(cost-center): 记住载体/Tab 选择,补配置引导并优化表格列宽

- 新增 usePersistedState:载体工作区与 Tab 存 localStorage(admin-cost-center:carrier / :tab),
  刷新后保持;存储值非法或 Tab 不适用于该载体时自动回退
- 成本项目增加配置说明与常用成本项模板(采购成本/打手报酬/人工成本/平台费/通道费/售后损失),
  成本项行新增「加版本」并按成本类型预选范围;成本项/版本弹窗补字段说明
- 商品与成本中心 8 张表统一 tableLayout=fixed,名称类列定宽 + 省略 + tooltip,
  表格最小宽度按列宽之和设置,避免名称列独占剩余宽度
This commit is contained in:
yml2213
2026-10-22 14:36:56 +08:00
parent b520a078b0
commit 0f7d9d4fad
2 changed files with 307 additions and 43 deletions
@@ -0,0 +1,43 @@
import { useCallback, useState } from 'react'
/**
* 把 UI 状态(tab、筛选等)持久化到 localStorage,刷新后保持。
*
* - 读取失败 / 值不合法时回退到 initial
* - setter 支持值或函数式更新
* - 写入失败(隐私模式、配额满)时静默忽略,不影响页面
*/
export function usePersistedState<T>(
key: string,
initial: T,
isValid?: (value: unknown) => value is T,
) {
const [value, setValue] = useState<T>(() => {
try {
const raw = window.localStorage.getItem(key)
if (raw === null) return initial
const parsed: unknown = JSON.parse(raw)
if (isValid && !isValid(parsed)) return initial
return parsed as T
} catch {
return initial
}
})
const setPersistedValue = useCallback(
(next: T | ((prev: T) => T)) => {
setValue((prev) => {
const resolved = typeof next === 'function' ? (next as (previous: T) => T)(prev) : next
try {
window.localStorage.setItem(key, JSON.stringify(resolved))
} catch {
// 本地存储不可用时只保留内存状态。
}
return resolved
})
},
[key],
)
return [value, setPersistedValue] as const
}
@@ -32,10 +32,11 @@ import {
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import type { Dayjs } from 'dayjs'
import PageHeader from '@/components/admin/PageHeader'
import { usePersistedState } from '@/hooks/usePersistedState'
import dayjs from '@/lib/dayjs'
import {
applyAdminTemplateProductBootstrap,
@@ -193,6 +194,92 @@ const CARRIER_WORKSPACES: Array<{
},
]
function tabKeysForCarrier(carrier: CarrierScope) {
return [
...(carrier === 'all' || carrier === 'work_order' ? ['products', 'mappings'] : []),
...(carrier === 'affiliate_dash' ? ['affiliate-skus'] : []),
'cost-items',
'cost-versions',
'exceptions',
'preview',
]
}
type CostItemPreset = {
key: string
label: string
code: string
name: string
costType: CostItem['costType']
carrierType: CostCarrierType
pricingMode: CostItem['pricingMode']
unitName: string
}
/** 常用成本项模板:避免每次手填编码/类型/计价方式。 */
const COST_ITEM_PRESETS: CostItemPreset[] = [
{
key: 'auto_purchase',
label: '自动发货采购成本',
code: 'auto_purchase',
name: '自动发货采购成本',
costType: 'auto_purchase',
carrierType: 'affiliate_dash',
pricingMode: 'per_item',
unitName: '件',
},
{
key: 'work_order_reward',
label: '接单平台打手报酬',
code: 'work_order_reward',
name: '接单平台打手报酬',
costType: 'work_order_reward',
carrierType: 'work_order',
pricingMode: 'per_item',
unitName: '件',
},
{
key: 'manual_service',
label: '人工客服成本',
code: 'manual_service',
name: '人工客服成本',
costType: 'manual_service',
carrierType: 'manual',
pricingMode: 'per_item',
unitName: '单',
},
{
key: 'platform_fee',
label: '平台服务费',
code: 'platform_fee',
name: '平台服务费',
costType: 'platform_fee',
carrierType: 'shared',
pricingMode: 'percent_of_revenue',
unitName: '单',
},
{
key: 'payment_fee',
label: '支付通道费',
code: 'payment_fee',
name: '支付通道费',
costType: 'payment_fee',
carrierType: 'shared',
pricingMode: 'percent_of_revenue',
unitName: '单',
},
{
key: 'after_sales_loss',
label: '售后损失',
code: 'after_sales_loss',
name: '售后损失',
costType: 'after_sales_loss',
carrierType: 'shared',
pricingMode: 'per_order',
unitName: '单',
},
]
const PRICING_OPTIONS = [
{ value: 'per_item', label: '按件' },
{ value: 'per_order', label: '按单' },
@@ -251,8 +338,13 @@ export default function AdminCostCenterPage() {
const [costItemForm] = Form.useForm<CostItemFormValues>()
const [versionForm] = Form.useForm<CostVersionFormValues>()
const [activeCarrier, setActiveCarrier] = useState<CarrierScope>('all')
const [activeTab, setActiveTab] = useState('products')
const [activeCarrier, setActiveCarrier] = usePersistedState<CarrierScope>(
'admin-cost-center:carrier',
'all',
(value): value is CarrierScope =>
value === 'all' || CARRIER_WORKSPACES.some((workspace) => workspace.key === value),
)
const [activeTab, setActiveTab] = usePersistedState<string>('admin-cost-center:tab', 'products')
const [productKeyword, setProductKeyword] = useState('')
const [productRootCategoryId, setProductRootCategoryId] = useState<number | null>(null)
const [productSubCategoryId, setProductSubCategoryId] = useState<number | null>(null)
@@ -305,15 +397,17 @@ export default function AdminCostCenterPage() {
const activeCarrierLabel =
activeCarrier === 'all' ? '全部载体' : labelOf(CARRIER_OPTIONS, activeCarrier)
// 商品目录 / 渠道映射只服务接单平台;自动发货只需上游 SKU + 采购成本。
const showsCatalogTabs = activeCarrier === 'all' || activeCarrier === 'work_order'
const visibleTabKeys = [
...(showsCatalogTabs ? ['products', 'mappings'] : []),
...(activeCarrier === 'affiliate_dash' ? ['affiliate-skus'] : []),
'cost-items',
'cost-versions',
'exceptions',
'preview',
]
const visibleTabKeys = tabKeysForCarrier(activeCarrier)
useEffect(() => {
const keys = tabKeysForCarrier(activeCarrier)
if (!keys.includes(activeTab)) {
setActiveTab(keys[0] ?? 'cost-items')
}
}, [activeCarrier, activeTab, setActiveTab])
const visibleCostItemPresets =
activeCarrier === 'all'
? COST_ITEM_PRESETS
: COST_ITEM_PRESETS.filter((preset) => preset.carrierType === activeCarrier)
const productsQuery = useQuery({
queryKey: [
@@ -682,9 +776,22 @@ export default function AdminCostCenterPage() {
}
}
function openCreateCostItem() {
function openCreateCostItem(preset?: CostItemPreset) {
setEditingCostItem(null)
costItemForm.resetFields()
if (preset) {
costItemForm.setFieldsValue({
code: preset.code,
name: preset.name,
costType: preset.costType,
carrierType: preset.carrierType,
pricingMode: preset.pricingMode,
unitName: preset.unitName,
status: 'active',
})
setCostItemModalOpen(true)
return
}
const defaultCostType =
activeCarrier === 'affiliate_dash'
? 'auto_purchase'
@@ -744,10 +851,20 @@ export default function AdminCostCenterPage() {
}
}
function openCreateVersion() {
function openCreateVersion(costItem?: CostItem) {
versionForm.resetFields()
const defaultScope: CostVersionFormValues['scopeType'] = costItem
? costItem.costType === 'auto_purchase'
? 'product'
: costItem.costType === 'work_order_reward'
? 'rule'
: 'global'
: activeCarrier === 'affiliate_dash'
? 'product'
: 'global'
versionForm.setFieldsValue({
scopeType: activeCarrier === 'affiliate_dash' ? 'product' : 'global',
costItemId: costItem?.id,
scopeType: defaultScope,
effectiveFrom: dayjs(),
status: 'active',
})
@@ -983,6 +1100,8 @@ export default function AdminCostCenterPage() {
{
title: '商品 / 分类',
dataIndex: 'canonicalName',
width: 460,
ellipsis: true,
render: (value: string, row) => (
<div style={{ minWidth: 0 }}>
<Typography.Text
@@ -1163,9 +1282,11 @@ export default function AdminCostCenterPage() {
render: (value: string, row) => (
<Space size={8} align="start">
<FolderOpenOutlined style={{ color: '#1677ff', marginTop: 4 }} />
<Space direction="vertical" size={0}>
<Typography.Text strong>{value}</Typography.Text>
<Typography.Text type="secondary">
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
<Typography.Text type="secondary" ellipsis={{ tooltip: row.canonicalSku }}>
{row.canonicalSku} · {row.unitName}
</Typography.Text>
</Space>
@@ -1269,8 +1390,8 @@ export default function AdminCostCenterPage() {
]
const costItemColumns: TableColumnsType<CostItem> = [
{ title: '编码', dataIndex: 'code', width: 180 },
{ title: '名称', dataIndex: 'name', minWidth: 160 },
{ title: '编码', dataIndex: 'code', width: 160 },
{ title: '名称', dataIndex: 'name', width: 320, ellipsis: true },
{
title: '类型',
dataIndex: 'costType',
@@ -1302,11 +1423,14 @@ export default function AdminCostCenterPage() {
},
{
title: '操作',
width: 140,
width: 210,
fixed: 'right',
render: (_, row) =>
canConfig ? (
<Space size={4}>
<Button size="small" type="primary" ghost onClick={() => openCreateVersion(row)}>
加版本
</Button>
<Button size="small" onClick={() => openEditCostItem(row)}>
编辑
</Button>
@@ -1322,11 +1446,16 @@ export default function AdminCostCenterPage() {
{
title: '成本项',
dataIndex: 'costItemName',
minWidth: 160,
width: 240,
ellipsis: true,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{value}</Typography.Text>
<Typography.Text type="secondary">{row.costItemCode}</Typography.Text>
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
<Typography.Text type="secondary" ellipsis={{ tooltip: row.costItemCode }}>
{row.costItemCode}
</Typography.Text>
</Space>
),
},
@@ -1341,7 +1470,8 @@ export default function AdminCostCenterPage() {
{
title: '范围',
dataIndex: 'scopeType',
width: 110,
width: 200,
ellipsis: true,
render: (value: string, row) => {
if (value === 'global') return '全局'
if (value === 'provider') return providerLabel(row.provider)
@@ -1417,7 +1547,7 @@ export default function AdminCostCenterPage() {
{
title: '原因',
dataIndex: 'reason',
minWidth: 220,
width: 360,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Typography.Text>{value}</Typography.Text>
@@ -1688,13 +1818,14 @@ export default function AdminCostCenterPage() {
) : null}
<Table
tableLayout="fixed"
rowKey="id"
size="small"
columns={productColumns}
dataSource={products}
loading={productsQuery.isLoading}
rowClassName={(row) => (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')}
scroll={{ x: 1080 }}
scroll={{ x: 1180 }}
pagination={buildAdminTablePagination({
current: productPage,
pageSize: productPageSize,
@@ -1780,12 +1911,13 @@ export default function AdminCostCenterPage() {
<Button onClick={() => setExpandedMappingProductIds([])}>收起全部</Button>
</Space>
<Table<ChannelProductMappingGroup>
tableLayout="fixed"
rowKey={(row) => String(row.canonicalProductId)}
size="small"
columns={mappingGroupColumns}
dataSource={mappingGroups}
loading={mappingsQuery.isLoading}
scroll={{ x: 1210 }}
scroll={{ x: 1100 }}
expandable={{
expandedRowKeys: expandedMappingProductIds,
onExpandedRowsChange: (keys) =>
@@ -1797,6 +1929,7 @@ export default function AdminCostCenterPage() {
该标准商品下共有 {group.mappings.length} 条渠道映射
</Typography.Text>
<Table<ChannelProductMapping>
tableLayout="fixed"
rowKey="id"
size="small"
columns={mappingDetailColumns}
@@ -1866,11 +1999,12 @@ export default function AdminCostCenterPage() {
message="自动发货只按上游货源 SKU 配采购成本:这里不维护游戏分类、渠道映射和商品上下架,那些只属于接单平台。"
/>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
dataSource={products}
loading={productsQuery.isLoading}
scroll={{ x: 900 }}
scroll={{ x: 1080 }}
pagination={buildAdminTablePagination({
current: productPage,
pageSize: productPageSize,
@@ -1885,10 +2019,20 @@ export default function AdminCostCenterPage() {
{
title: 'SKU 名称',
dataIndex: 'canonicalName',
width: 320,
ellipsis: true,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{value}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
ellipsis={{
tooltip: String(row.attributes?.skuCode || row.canonicalSku),
}}
>
{String(row.attributes?.skuCode || row.canonicalSku)}
</Typography.Text>
</Space>
@@ -1974,12 +2118,48 @@ export default function AdminCostCenterPage() {
}
extra={
canConfig ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateCostItem}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => openCreateCostItem()}
>
新建成本项
</Button>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="成本项目 = 有哪些成本;成本版本 = 这笔成本多少钱、对谁生效"
description={
<Space direction="vertical" size={2}>
<span>
1. 先建成本项目:如「自动发货采购成本」「接单平台打手报酬」「平台服务费」;
</span>
<span>
2. 再建成本版本:同一成本项可有多条,按范围优先级 接单规则 &gt; 标准商品
&gt; 店铺 &gt; 来源 &gt; 全局 命中;
</span>
<span>3. 改价只新增版本,历史订单走成本快照。</span>
</Space>
}
/>
{canConfig && visibleCostItemPresets.length > 0 ? (
<Space style={{ marginBottom: 12 }} wrap>
<Typography.Text type="secondary">常用成本项:</Typography.Text>
{visibleCostItemPresets.map((preset) => (
<Button
key={preset.key}
size="small"
onClick={() => openCreateCostItem(preset)}
>
+ {preset.label}
</Button>
))}
</Space>
) : null}
<Space style={{ marginBottom: 16 }}>
<Input
allowClear
@@ -1994,12 +2174,13 @@ export default function AdminCostCenterPage() {
/>
</Space>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
columns={costItemColumns}
dataSource={costItems}
loading={costItemsQuery.isLoading}
scroll={{ x: 1000 }}
scroll={{ x: 1250 }}
pagination={buildAdminTablePagination({
current: costItemPage,
pageSize: costItemPageSize,
@@ -2029,12 +2210,23 @@ export default function AdminCostCenterPage() {
}
extra={
canConfig ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateVersion}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => openCreateVersion()}
>
新建版本
</Button>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="成本版本 = 给成本项定价:一条版本只表达「多少钱 + 何时生效 + 对谁适用」"
description="按件/按单/按比例计价;范围优先级 接单规则 > 标准商品 > 店铺 > 来源 > 全局。改价请新建版本,不要改已生效版本;每个成本项建议只保留一个生效版本。"
/>
<Space style={{ marginBottom: 16 }} wrap>
<Input
allowClear
@@ -2064,12 +2256,13 @@ export default function AdminCostCenterPage() {
/>
</Space>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
columns={versionColumns}
dataSource={versions}
loading={versionsQuery.isLoading}
scroll={{ x: 1210 }}
scroll={{ x: 1100 }}
pagination={buildAdminTablePagination({
current: versionPage,
pageSize: versionPageSize,
@@ -2108,6 +2301,7 @@ export default function AdminCostCenterPage() {
}
>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
columns={exceptionColumns}
@@ -2207,27 +2401,31 @@ export default function AdminCostCenterPage() {
) : null}
<Table
tableLayout="fixed"
rowKey={(row) => `${row.costItemId}-${row.costVersionId}`}
size="small"
pagination={false}
dataSource={preview.lines}
columns={[
{ title: '成本项', dataIndex: 'costItemName' },
{ title: '类型', dataIndex: 'costType' },
{ title: '范围', dataIndex: 'scopeType' },
{ title: '成本项', dataIndex: 'costItemName', width: 220 },
{ title: '类型', dataIndex: 'costType', width: 120 },
{ title: '范围', dataIndex: 'scopeType', width: 160 },
{
title: '数量',
dataIndex: 'quantity',
width: 90,
render: (value: number) => Number(value || 0).toFixed(3),
},
{
title: '单位成本',
dataIndex: 'unitAmountFen',
width: 110,
render: (value: number) => formatMoney(value),
},
{
title: '合计',
dataIndex: 'totalAmountFen',
width: 110,
render: (value: number) => formatMoney(value),
},
]}
@@ -2398,19 +2596,34 @@ export default function AdminCostCenterPage() {
destroyOnClose
>
<Form form={costItemForm} layout="vertical" onFinish={submitCostItem}>
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请输入编码' }]}>
<Form.Item
name="code"
label="编码"
rules={[{ required: true, message: '请输入编码' }]}
extra="建议英文小写 + 下划线,例如 auto_purchase、work_order_reward;创建后不可修改。"
>
<Input placeholder="例如:work_order_reward" disabled={Boolean(editingCostItem)} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:接单平台打手报酬" />
</Form.Item>
<Form.Item name="costType" label="成本类型" rules={[{ required: true }]}>
<Form.Item
name="costType"
label="成本类型"
rules={[{ required: true }]}
extra="决定成本归到哪一类报表:自动发货选采购成本,接单平台选打手报酬,平台/支付费选对应类型。"
>
<Select options={COST_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="carrierType" label="适用载体" rules={[{ required: true }]}>
<Select options={CARRIER_OPTIONS} />
</Form.Item>
<Form.Item name="pricingMode" label="计价方式" rules={[{ required: true }]}>
<Form.Item
name="pricingMode"
label="计价方式"
rules={[{ required: true }]}
extra="按件 = 数量 × 单价;按单 = 每单固定金额;按比例 = 订单金额 × 百分比;按天固定 = 每天固定成本。"
>
<Select options={PRICING_OPTIONS} />
</Form.Item>
<Form.Item name="unitName" label="单位">
@@ -2440,6 +2653,13 @@ export default function AdminCostCenterPage() {
width={620}
destroyOnClose
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="一条版本 = 一个价格 + 一个生效区间 + 一个适用范围"
description="优先级:接单规则 > 标准商品 > 店铺 > 来源 > 全局。改价请新建版本,旧版本停用即可,历史订单按快照不变。"
/>
<Form form={versionForm} layout="vertical" onFinish={submitVersion}>
<Form.Item name="costItemId" label="成本项" rules={[{ required: true }]}>
<Select showSearch options={costItemOptions} optionFilterProp="label" />
@@ -2501,6 +2721,7 @@ export default function AdminCostCenterPage() {
<Form.Item
name="amountYuan"
label="固定金额(元)"
extra="按件时填单件成本;按单时填整单成本。"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />